feat:테넌트설정, 공통코드관리, 메뉴동기화 기능 추가
- 테넌트 설정 페이지 (재고관리 품목유형 등) - 공통코드 관리 페이지 (글로벌/테넌트별 코드 관리) - 메뉴 동기화 기능 (로컬↔개발↔운영 환경간 메뉴 Push/Pull)
This commit is contained in:
373
app/Http/Controllers/CommonCodeController.php
Normal file
373
app/Http/Controllers/CommonCodeController.php
Normal file
@@ -0,0 +1,373 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Products\CommonCode;
|
||||
use App\Models\Tenants\Tenant;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class CommonCodeController extends Controller
|
||||
{
|
||||
/**
|
||||
* 코드 그룹 라벨
|
||||
*/
|
||||
private const CODE_GROUP_LABELS = [
|
||||
'item_type' => '품목유형',
|
||||
'material_type' => '자재유형',
|
||||
'client_type' => '거래처유형',
|
||||
'order_status' => '주문상태',
|
||||
'order_type' => '주문유형',
|
||||
'delivery_method' => '배송방법',
|
||||
'tenant_type' => '테넌트유형',
|
||||
'product_category' => '제품분류',
|
||||
'motor_type' => '모터유형',
|
||||
'controller_type' => '컨트롤러유형',
|
||||
'painting_type' => '도장유형',
|
||||
'position_type' => '위치유형',
|
||||
'capability_profile' => '생산능력',
|
||||
'bad_debt_progress' => '대손진행',
|
||||
'height_construction_cost' => '높이시공비',
|
||||
'width_construction_cost' => '폭시공비',
|
||||
];
|
||||
|
||||
/**
|
||||
* 공통코드 관리 페이지
|
||||
*/
|
||||
public function index(Request $request): View|Response
|
||||
{
|
||||
// HTMX 요청 시 전체 페이지 리로드
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('common-codes.index'));
|
||||
}
|
||||
|
||||
$tenantId = session('selected_tenant_id');
|
||||
$tenant = $tenantId ? Tenant::find($tenantId) : null;
|
||||
$isHQ = $tenant?->tenant_type === 'HQ';
|
||||
|
||||
// 선택된 코드 그룹 (기본: item_type)
|
||||
$selectedGroup = $request->get('group', 'item_type');
|
||||
|
||||
// 코드 그룹 목록 (실제 존재하는 그룹만)
|
||||
$existingGroups = CommonCode::query()
|
||||
->select('code_group')
|
||||
->distinct()
|
||||
->pluck('code_group')
|
||||
->toArray();
|
||||
|
||||
$codeGroups = collect(self::CODE_GROUP_LABELS)
|
||||
->filter(fn($label, $group) => in_array($group, $existingGroups))
|
||||
->toArray();
|
||||
|
||||
// 선택된 그룹의 코드 목록
|
||||
$globalCodes = collect();
|
||||
$tenantCodes = collect();
|
||||
|
||||
if ($tenantId && isset($codeGroups[$selectedGroup])) {
|
||||
// 글로벌 코드 (tenant_id IS NULL)
|
||||
$globalCodes = CommonCode::query()
|
||||
->whereNull('tenant_id')
|
||||
->where('code_group', $selectedGroup)
|
||||
->orderBy('sort_order')
|
||||
->get();
|
||||
|
||||
// 테넌트 코드
|
||||
$tenantCodes = CommonCode::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('code_group', $selectedGroup)
|
||||
->orderBy('sort_order')
|
||||
->get();
|
||||
}
|
||||
|
||||
return view('common-codes.index', [
|
||||
'tenant' => $tenant,
|
||||
'isHQ' => $isHQ,
|
||||
'codeGroups' => $codeGroups,
|
||||
'selectedGroup' => $selectedGroup,
|
||||
'globalCodes' => $globalCodes,
|
||||
'tenantCodes' => $tenantCodes,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 코드 저장 (신규/수정)
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
$tenant = $tenantId ? Tenant::find($tenantId) : null;
|
||||
|
||||
if (! $tenantId) {
|
||||
return redirect()->back()->with('error', '테넌트를 먼저 선택해주세요.');
|
||||
}
|
||||
|
||||
$isHQ = $tenant?->tenant_type === 'HQ';
|
||||
$isGlobal = $request->boolean('is_global');
|
||||
|
||||
// 글로벌 코드는 HQ만 생성 가능
|
||||
if ($isGlobal && ! $isHQ) {
|
||||
return redirect()->back()->with('error', '글로벌 코드는 본사만 생성할 수 있습니다.');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'code_group' => 'required|string|max:50',
|
||||
'code' => 'required|string|max:50',
|
||||
'name' => 'required|string|max:100',
|
||||
'sort_order' => 'nullable|integer|min:0|max:9999',
|
||||
'attributes' => 'nullable|json',
|
||||
'is_global' => 'nullable|boolean',
|
||||
]);
|
||||
|
||||
// 중복 체크
|
||||
$targetTenantId = $isGlobal ? null : $tenantId;
|
||||
$exists = CommonCode::query()
|
||||
->where('tenant_id', $targetTenantId)
|
||||
->where('code_group', $validated['code_group'])
|
||||
->where('code', $validated['code'])
|
||||
->exists();
|
||||
|
||||
if ($exists) {
|
||||
return redirect()->back()
|
||||
->with('error', '이미 존재하는 코드입니다.')
|
||||
->withInput();
|
||||
}
|
||||
|
||||
CommonCode::create([
|
||||
'tenant_id' => $targetTenantId,
|
||||
'code_group' => $validated['code_group'],
|
||||
'code' => $validated['code'],
|
||||
'name' => $validated['name'],
|
||||
'sort_order' => $validated['sort_order'] ?? 0,
|
||||
'attributes' => $validated['attributes'] ? json_decode($validated['attributes'], true) : null,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('common-codes.index', ['group' => $validated['code_group']])
|
||||
->with('success', '코드가 추가되었습니다.');
|
||||
}
|
||||
|
||||
/**
|
||||
* 코드 수정
|
||||
*/
|
||||
public function update(Request $request, int $id): RedirectResponse|JsonResponse
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
$tenant = $tenantId ? Tenant::find($tenantId) : null;
|
||||
|
||||
if (! $tenantId) {
|
||||
if ($request->ajax()) {
|
||||
return response()->json(['error' => '테넌트를 먼저 선택해주세요.'], 400);
|
||||
}
|
||||
return redirect()->back()->with('error', '테넌트를 먼저 선택해주세요.');
|
||||
}
|
||||
|
||||
$isHQ = $tenant?->tenant_type === 'HQ';
|
||||
|
||||
$code = CommonCode::find($id);
|
||||
if (! $code) {
|
||||
if ($request->ajax()) {
|
||||
return response()->json(['error' => '코드를 찾을 수 없습니다.'], 404);
|
||||
}
|
||||
return redirect()->back()->with('error', '코드를 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
// 권한 체크: 글로벌 코드는 HQ만, 테넌트 코드는 해당 테넌트만
|
||||
if ($code->tenant_id === null && ! $isHQ) {
|
||||
if ($request->ajax()) {
|
||||
return response()->json(['error' => '글로벌 코드는 본사만 수정할 수 있습니다.'], 403);
|
||||
}
|
||||
return redirect()->back()->with('error', '글로벌 코드는 본사만 수정할 수 있습니다.');
|
||||
}
|
||||
|
||||
if ($code->tenant_id !== null && $code->tenant_id !== $tenantId) {
|
||||
if ($request->ajax()) {
|
||||
return response()->json(['error' => '다른 테넌트의 코드는 수정할 수 없습니다.'], 403);
|
||||
}
|
||||
return redirect()->back()->with('error', '다른 테넌트의 코드는 수정할 수 없습니다.');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'sometimes|required|string|max:100',
|
||||
'sort_order' => 'sometimes|nullable|integer|min:0|max:9999',
|
||||
'attributes' => 'sometimes|nullable|json',
|
||||
'is_active' => 'sometimes|boolean',
|
||||
]);
|
||||
|
||||
// 필드별 업데이트
|
||||
if (isset($validated['name'])) {
|
||||
$code->name = $validated['name'];
|
||||
}
|
||||
if (array_key_exists('sort_order', $validated)) {
|
||||
$code->sort_order = $validated['sort_order'] ?? 0;
|
||||
}
|
||||
if (array_key_exists('attributes', $validated)) {
|
||||
$code->attributes = $validated['attributes'] ? json_decode($validated['attributes'], true) : null;
|
||||
}
|
||||
if (isset($validated['is_active'])) {
|
||||
$code->is_active = $validated['is_active'];
|
||||
}
|
||||
|
||||
$code->save();
|
||||
|
||||
if ($request->ajax()) {
|
||||
return response()->json(['success' => true, 'message' => '수정되었습니다.']);
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('common-codes.index', ['group' => $code->code_group])
|
||||
->with('success', '코드가 수정되었습니다.');
|
||||
}
|
||||
|
||||
/**
|
||||
* 활성화 토글 (AJAX)
|
||||
*/
|
||||
public function toggle(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
$tenant = $tenantId ? Tenant::find($tenantId) : null;
|
||||
|
||||
if (! $tenantId) {
|
||||
return response()->json(['error' => '테넌트를 먼저 선택해주세요.'], 400);
|
||||
}
|
||||
|
||||
$isHQ = $tenant?->tenant_type === 'HQ';
|
||||
|
||||
$code = CommonCode::find($id);
|
||||
if (! $code) {
|
||||
return response()->json(['error' => '코드를 찾을 수 없습니다.'], 404);
|
||||
}
|
||||
|
||||
// 권한 체크
|
||||
if ($code->tenant_id === null && ! $isHQ) {
|
||||
return response()->json(['error' => '글로벌 코드는 본사만 수정할 수 있습니다.'], 403);
|
||||
}
|
||||
|
||||
if ($code->tenant_id !== null && $code->tenant_id !== $tenantId) {
|
||||
return response()->json(['error' => '다른 테넌트의 코드는 수정할 수 없습니다.'], 403);
|
||||
}
|
||||
|
||||
$code->is_active = ! $code->is_active;
|
||||
$code->save();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'is_active' => $code->is_active,
|
||||
'message' => $code->is_active ? '활성화되었습니다.' : '비활성화되었습니다.',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 글로벌 코드를 테넌트용으로 복사
|
||||
*/
|
||||
public function copy(Request $request, int $id): RedirectResponse|JsonResponse
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
if (! $tenantId) {
|
||||
if ($request->ajax()) {
|
||||
return response()->json(['error' => '테넌트를 먼저 선택해주세요.'], 400);
|
||||
}
|
||||
return redirect()->back()->with('error', '테넌트를 먼저 선택해주세요.');
|
||||
}
|
||||
|
||||
$globalCode = CommonCode::whereNull('tenant_id')->find($id);
|
||||
if (! $globalCode) {
|
||||
if ($request->ajax()) {
|
||||
return response()->json(['error' => '글로벌 코드를 찾을 수 없습니다.'], 404);
|
||||
}
|
||||
return redirect()->back()->with('error', '글로벌 코드를 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
// 이미 복사된 코드가 있는지 확인
|
||||
$exists = CommonCode::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('code_group', $globalCode->code_group)
|
||||
->where('code', $globalCode->code)
|
||||
->exists();
|
||||
|
||||
if ($exists) {
|
||||
if ($request->ajax()) {
|
||||
return response()->json(['error' => '이미 복사된 코드가 있습니다.'], 400);
|
||||
}
|
||||
return redirect()->back()->with('error', '이미 복사된 코드가 있습니다.');
|
||||
}
|
||||
|
||||
// 복사
|
||||
CommonCode::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'code_group' => $globalCode->code_group,
|
||||
'code' => $globalCode->code,
|
||||
'name' => $globalCode->name,
|
||||
'sort_order' => $globalCode->sort_order,
|
||||
'attributes' => $globalCode->attributes,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
if ($request->ajax()) {
|
||||
return response()->json(['success' => true, 'message' => '코드가 복사되었습니다.']);
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('common-codes.index', ['group' => $globalCode->code_group])
|
||||
->with('success', '글로벌 코드가 테넌트용으로 복사되었습니다.');
|
||||
}
|
||||
|
||||
/**
|
||||
* 코드 삭제 (테넌트 코드만)
|
||||
*/
|
||||
public function destroy(Request $request, int $id): RedirectResponse|JsonResponse
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
$tenant = $tenantId ? Tenant::find($tenantId) : null;
|
||||
|
||||
if (! $tenantId) {
|
||||
if ($request->ajax()) {
|
||||
return response()->json(['error' => '테넌트를 먼저 선택해주세요.'], 400);
|
||||
}
|
||||
return redirect()->back()->with('error', '테넌트를 먼저 선택해주세요.');
|
||||
}
|
||||
|
||||
$isHQ = $tenant?->tenant_type === 'HQ';
|
||||
|
||||
$code = CommonCode::find($id);
|
||||
if (! $code) {
|
||||
if ($request->ajax()) {
|
||||
return response()->json(['error' => '코드를 찾을 수 없습니다.'], 404);
|
||||
}
|
||||
return redirect()->back()->with('error', '코드를 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
// 글로벌 코드 삭제는 HQ만
|
||||
if ($code->tenant_id === null && ! $isHQ) {
|
||||
if ($request->ajax()) {
|
||||
return response()->json(['error' => '글로벌 코드는 본사만 삭제할 수 있습니다.'], 403);
|
||||
}
|
||||
return redirect()->back()->with('error', '글로벌 코드는 본사만 삭제할 수 있습니다.');
|
||||
}
|
||||
|
||||
// 다른 테넌트 코드 삭제 불가
|
||||
if ($code->tenant_id !== null && $code->tenant_id !== $tenantId) {
|
||||
if ($request->ajax()) {
|
||||
return response()->json(['error' => '다른 테넌트의 코드는 삭제할 수 없습니다.'], 403);
|
||||
}
|
||||
return redirect()->back()->with('error', '다른 테넌트의 코드는 삭제할 수 없습니다.');
|
||||
}
|
||||
|
||||
$codeGroup = $code->code_group;
|
||||
$code->delete();
|
||||
|
||||
if ($request->ajax()) {
|
||||
return response()->json(['success' => true, 'message' => '코드가 삭제되었습니다.']);
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('common-codes.index', ['group' => $codeGroup])
|
||||
->with('success', '코드가 삭제되었습니다.');
|
||||
}
|
||||
}
|
||||
467
app/Http/Controllers/MenuSyncController.php
Normal file
467
app/Http/Controllers/MenuSyncController.php
Normal file
@@ -0,0 +1,467 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Commons\Menu;
|
||||
use App\Models\Tenants\TenantSetting;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
class MenuSyncController extends Controller
|
||||
{
|
||||
protected int $tenantId = 1; // MNG 메뉴는 tenant_id=1
|
||||
|
||||
/**
|
||||
* 환경 설정 조회
|
||||
*/
|
||||
private function getEnvironments(): array
|
||||
{
|
||||
$setting = TenantSetting::withoutGlobalScopes()
|
||||
->where('tenant_id', $this->tenantId)
|
||||
->where('setting_group', 'menu_sync')
|
||||
->where('setting_key', 'environments')
|
||||
->first();
|
||||
|
||||
return $setting?->setting_value ?? [
|
||||
'dev' => ['name' => '개발', 'url' => '', 'api_key' => ''],
|
||||
'prod' => ['name' => '운영', 'url' => '', 'api_key' => ''],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 메뉴 동기화 페이지
|
||||
*/
|
||||
public function index(Request $request): View|Response
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('menus.sync.index'));
|
||||
}
|
||||
|
||||
$environments = $this->getEnvironments();
|
||||
$selectedEnv = $request->get('env', 'dev');
|
||||
|
||||
// 로컬 메뉴 조회 (트리 구조)
|
||||
$localMenus = $this->getMenuTree();
|
||||
|
||||
// 원격 메뉴 조회
|
||||
$remoteMenus = [];
|
||||
$remoteError = null;
|
||||
|
||||
if (! empty($environments[$selectedEnv]['url'])) {
|
||||
try {
|
||||
$remoteMenus = $this->fetchRemoteMenus($environments[$selectedEnv]);
|
||||
} catch (\Exception $e) {
|
||||
$remoteError = $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
// 차이점 계산
|
||||
$diff = $this->calculateDiff($localMenus, $remoteMenus);
|
||||
|
||||
return view('menus.sync', [
|
||||
'environments' => $environments,
|
||||
'selectedEnv' => $selectedEnv,
|
||||
'localMenus' => $localMenus,
|
||||
'remoteMenus' => $remoteMenus,
|
||||
'remoteError' => $remoteError,
|
||||
'diff' => $diff,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 환경 설정 저장
|
||||
*/
|
||||
public function saveSettings(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'environments' => 'required|array',
|
||||
'environments.*.name' => 'required|string|max:50',
|
||||
'environments.*.url' => 'nullable|url|max:255',
|
||||
'environments.*.api_key' => 'nullable|string|max:255',
|
||||
]);
|
||||
|
||||
TenantSetting::withoutGlobalScopes()->updateOrCreate(
|
||||
[
|
||||
'tenant_id' => $this->tenantId,
|
||||
'setting_group' => 'menu_sync',
|
||||
'setting_key' => 'environments',
|
||||
],
|
||||
[
|
||||
'setting_value' => $validated['environments'],
|
||||
'description' => '메뉴 동기화 환경 설정',
|
||||
]
|
||||
);
|
||||
|
||||
return response()->json(['success' => true, 'message' => '환경 설정이 저장되었습니다.']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 메뉴 Export API (다른 환경에서 호출)
|
||||
*/
|
||||
public function export(Request $request): JsonResponse
|
||||
{
|
||||
// API Key 검증
|
||||
$apiKey = $request->header('X-Menu-Sync-Key');
|
||||
$validKey = config('app.menu_sync_api_key', env('MENU_SYNC_API_KEY'));
|
||||
|
||||
if (empty($validKey) || $apiKey !== $validKey) {
|
||||
return response()->json(['error' => 'Unauthorized'], 401);
|
||||
}
|
||||
|
||||
$menus = $this->getMenuTree();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'environment' => config('app.env'),
|
||||
'exported_at' => now()->toIso8601String(),
|
||||
'menus' => $menus,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 메뉴 Import API (다른 환경에서 호출)
|
||||
*/
|
||||
public function import(Request $request): JsonResponse
|
||||
{
|
||||
// API Key 검증
|
||||
$apiKey = $request->header('X-Menu-Sync-Key');
|
||||
$validKey = config('app.menu_sync_api_key', env('MENU_SYNC_API_KEY'));
|
||||
|
||||
if (empty($validKey) || $apiKey !== $validKey) {
|
||||
return response()->json(['error' => 'Unauthorized'], 401);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'menus' => 'required|array',
|
||||
'menus.*.name' => 'required|string|max:100',
|
||||
'menus.*.url' => 'required|string|max:255',
|
||||
'menus.*.icon' => 'nullable|string|max:50',
|
||||
'menus.*.sort_order' => 'nullable|integer',
|
||||
'menus.*.options' => 'nullable|array',
|
||||
'menus.*.parent_name' => 'nullable|string', // 부모 메뉴 이름으로 연결
|
||||
'menus.*.children' => 'nullable|array',
|
||||
]);
|
||||
|
||||
$imported = 0;
|
||||
foreach ($validated['menus'] as $menuData) {
|
||||
$this->importMenu($menuData);
|
||||
$imported++;
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => "{$imported}개 메뉴가 동기화되었습니다.",
|
||||
'imported' => $imported,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 개별 메뉴 Push (로컬 → 원격)
|
||||
*/
|
||||
public function push(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'env' => 'required|string|in:dev,prod',
|
||||
'menu_ids' => 'required|array|min:1',
|
||||
'menu_ids.*' => 'integer',
|
||||
]);
|
||||
|
||||
$environments = $this->getEnvironments();
|
||||
$env = $environments[$validated['env']] ?? null;
|
||||
|
||||
if (! $env || empty($env['url'])) {
|
||||
return response()->json(['error' => '환경 설정이 없습니다.'], 400);
|
||||
}
|
||||
|
||||
// 선택된 메뉴 조회
|
||||
$menus = Menu::withoutGlobalScopes()
|
||||
->where('tenant_id', $this->tenantId)
|
||||
->whereIn('id', $validated['menu_ids'])
|
||||
->get();
|
||||
|
||||
if ($menus->isEmpty()) {
|
||||
return response()->json(['error' => '선택된 메뉴가 없습니다.'], 400);
|
||||
}
|
||||
|
||||
// 메뉴 데이터 준비 (부모 정보 포함)
|
||||
$menuData = $menus->map(function ($menu) {
|
||||
$parent = $menu->parent_id
|
||||
? Menu::withoutGlobalScopes()->find($menu->parent_id)
|
||||
: null;
|
||||
|
||||
return [
|
||||
'name' => $menu->name,
|
||||
'url' => $menu->url,
|
||||
'icon' => $menu->icon,
|
||||
'sort_order' => $menu->sort_order,
|
||||
'options' => $menu->options,
|
||||
'parent_name' => $parent?->name,
|
||||
'children' => $this->getChildrenData($menu->id),
|
||||
];
|
||||
})->toArray();
|
||||
|
||||
// 원격 서버로 전송
|
||||
try {
|
||||
$response = Http::withHeaders([
|
||||
'X-Menu-Sync-Key' => $env['api_key'],
|
||||
'Accept' => 'application/json',
|
||||
])->post(rtrim($env['url'], '/') . '/menu-sync/import', [
|
||||
'menus' => $menuData,
|
||||
]);
|
||||
|
||||
if ($response->successful()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => $response->json('message', '동기화 완료'),
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'error' => $response->json('error', '원격 서버 오류'),
|
||||
], $response->status());
|
||||
} catch (\Exception $e) {
|
||||
return response()->json(['error' => '연결 실패: ' . $e->getMessage()], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 개별 메뉴 Pull (원격 → 로컬)
|
||||
*/
|
||||
public function pull(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'env' => 'required|string|in:dev,prod',
|
||||
'menu_names' => 'required|array|min:1',
|
||||
'menu_names.*' => 'string',
|
||||
]);
|
||||
|
||||
$environments = $this->getEnvironments();
|
||||
$env = $environments[$validated['env']] ?? null;
|
||||
|
||||
if (! $env || empty($env['url'])) {
|
||||
return response()->json(['error' => '환경 설정이 없습니다.'], 400);
|
||||
}
|
||||
|
||||
// 원격 메뉴 조회
|
||||
try {
|
||||
$remoteMenus = $this->fetchRemoteMenus($env);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json(['error' => $e->getMessage()], 500);
|
||||
}
|
||||
|
||||
// 선택된 메뉴만 필터링
|
||||
$selectedMenus = $this->filterMenusByName($remoteMenus, $validated['menu_names']);
|
||||
|
||||
if (empty($selectedMenus)) {
|
||||
return response()->json(['error' => '선택된 메뉴를 찾을 수 없습니다.'], 400);
|
||||
}
|
||||
|
||||
// 로컬에 Import
|
||||
$imported = 0;
|
||||
foreach ($selectedMenus as $menuData) {
|
||||
$this->importMenu($menuData);
|
||||
$imported++;
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => "{$imported}개 메뉴가 동기화되었습니다.",
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 연결 테스트
|
||||
*/
|
||||
public function testConnection(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'url' => 'required|url',
|
||||
'api_key' => 'required|string',
|
||||
]);
|
||||
|
||||
try {
|
||||
$response = Http::withHeaders([
|
||||
'X-Menu-Sync-Key' => $validated['api_key'],
|
||||
'Accept' => 'application/json',
|
||||
])->timeout(10)->get(rtrim($validated['url'], '/') . '/menu-sync/export');
|
||||
|
||||
if ($response->successful()) {
|
||||
$data = $response->json();
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '연결 성공',
|
||||
'environment' => $data['environment'] ?? 'unknown',
|
||||
'menu_count' => count($data['menus'] ?? []),
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'API 오류: ' . $response->status(),
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '연결 실패: ' . $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 메뉴 트리 조회
|
||||
*/
|
||||
private function getMenuTree(?int $parentId = null): array
|
||||
{
|
||||
$menus = Menu::withoutGlobalScopes()
|
||||
->where('tenant_id', $this->tenantId)
|
||||
->where('parent_id', $parentId)
|
||||
->orderBy('sort_order')
|
||||
->get();
|
||||
|
||||
return $menus->map(function ($menu) {
|
||||
return [
|
||||
'id' => $menu->id,
|
||||
'name' => $menu->name,
|
||||
'url' => $menu->url,
|
||||
'icon' => $menu->icon,
|
||||
'sort_order' => $menu->sort_order,
|
||||
'options' => $menu->options,
|
||||
'children' => $this->getMenuTree($menu->id),
|
||||
];
|
||||
})->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* 자식 메뉴 데이터 조회
|
||||
*/
|
||||
private function getChildrenData(int $parentId): array
|
||||
{
|
||||
$children = Menu::withoutGlobalScopes()
|
||||
->where('tenant_id', $this->tenantId)
|
||||
->where('parent_id', $parentId)
|
||||
->orderBy('sort_order')
|
||||
->get();
|
||||
|
||||
return $children->map(function ($menu) {
|
||||
return [
|
||||
'name' => $menu->name,
|
||||
'url' => $menu->url,
|
||||
'icon' => $menu->icon,
|
||||
'sort_order' => $menu->sort_order,
|
||||
'options' => $menu->options,
|
||||
'children' => $this->getChildrenData($menu->id),
|
||||
];
|
||||
})->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* 원격 메뉴 조회
|
||||
*/
|
||||
private function fetchRemoteMenus(array $env): array
|
||||
{
|
||||
$response = Http::withHeaders([
|
||||
'X-Menu-Sync-Key' => $env['api_key'],
|
||||
'Accept' => 'application/json',
|
||||
])->timeout(10)->get(rtrim($env['url'], '/') . '/menu-sync/export');
|
||||
|
||||
if (! $response->successful()) {
|
||||
throw new \Exception('API 오류: HTTP ' . $response->status());
|
||||
}
|
||||
|
||||
$data = $response->json();
|
||||
if (! isset($data['menus'])) {
|
||||
throw new \Exception('잘못된 응답 형식');
|
||||
}
|
||||
|
||||
return $data['menus'];
|
||||
}
|
||||
|
||||
/**
|
||||
* 메뉴 차이점 계산
|
||||
*/
|
||||
private function calculateDiff(array $localMenus, array $remoteMenus): array
|
||||
{
|
||||
$localNames = $this->flattenMenuNames($localMenus);
|
||||
$remoteNames = $this->flattenMenuNames($remoteMenus);
|
||||
|
||||
return [
|
||||
'local_only' => array_diff($localNames, $remoteNames),
|
||||
'remote_only' => array_diff($remoteNames, $localNames),
|
||||
'both' => array_intersect($localNames, $remoteNames),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 메뉴 이름 평탄화
|
||||
*/
|
||||
private function flattenMenuNames(array $menus, string $prefix = ''): array
|
||||
{
|
||||
$names = [];
|
||||
foreach ($menus as $menu) {
|
||||
$fullName = $prefix ? "{$prefix} > {$menu['name']}" : $menu['name'];
|
||||
$names[] = $menu['name'];
|
||||
if (! empty($menu['children'])) {
|
||||
$names = array_merge($names, $this->flattenMenuNames($menu['children'], $fullName));
|
||||
}
|
||||
}
|
||||
return $names;
|
||||
}
|
||||
|
||||
/**
|
||||
* 이름으로 메뉴 필터링
|
||||
*/
|
||||
private function filterMenusByName(array $menus, array $names): array
|
||||
{
|
||||
$result = [];
|
||||
foreach ($menus as $menu) {
|
||||
if (in_array($menu['name'], $names)) {
|
||||
$result[] = $menu;
|
||||
}
|
||||
if (! empty($menu['children'])) {
|
||||
$result = array_merge($result, $this->filterMenusByName($menu['children'], $names));
|
||||
}
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 메뉴 Import
|
||||
*/
|
||||
private function importMenu(array $data, ?int $parentId = null): void
|
||||
{
|
||||
// 부모 메뉴 찾기
|
||||
if (! $parentId && ! empty($data['parent_name'])) {
|
||||
$parent = Menu::withoutGlobalScopes()
|
||||
->where('tenant_id', $this->tenantId)
|
||||
->where('name', $data['parent_name'])
|
||||
->first();
|
||||
$parentId = $parent?->id;
|
||||
}
|
||||
|
||||
// 기존 메뉴 찾기 또는 생성
|
||||
$menu = Menu::withoutGlobalScopes()->updateOrCreate(
|
||||
[
|
||||
'tenant_id' => $this->tenantId,
|
||||
'name' => $data['name'],
|
||||
],
|
||||
[
|
||||
'parent_id' => $parentId,
|
||||
'url' => $data['url'],
|
||||
'icon' => $data['icon'] ?? null,
|
||||
'sort_order' => $data['sort_order'] ?? 0,
|
||||
'options' => $data['options'] ?? null,
|
||||
'is_active' => true,
|
||||
]
|
||||
);
|
||||
|
||||
// 자식 메뉴 처리
|
||||
if (! empty($data['children'])) {
|
||||
foreach ($data['children'] as $child) {
|
||||
$this->importMenu($child, $menu->id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
127
app/Http/Controllers/TenantSettingController.php
Normal file
127
app/Http/Controllers/TenantSettingController.php
Normal file
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Products\CommonCode;
|
||||
use App\Models\Tenants\Tenant;
|
||||
use App\Models\Tenants\TenantSetting;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class TenantSettingController extends Controller
|
||||
{
|
||||
/**
|
||||
* 설정 목록 (재고 설정 페이지)
|
||||
*/
|
||||
public function index(Request $request): View|Response
|
||||
{
|
||||
// HTMX 요청 시 전체 페이지 리로드
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('tenant-settings.index'));
|
||||
}
|
||||
|
||||
$tenantId = session('selected_tenant_id');
|
||||
$tenant = $tenantId ? Tenant::find($tenantId) : null;
|
||||
|
||||
// 품목유형 목록 (common_codes에서 조회)
|
||||
$itemTypeLabels = $tenantId ? CommonCode::getItemTypes($tenantId) : [];
|
||||
|
||||
// 테넌트 미선택 시 빈 설정
|
||||
$stockSettings = collect();
|
||||
if ($tenantId) {
|
||||
$stockSettings = TenantSetting::withoutGlobalScopes()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('setting_group', 'stock')
|
||||
->get()
|
||||
->keyBy('setting_key');
|
||||
}
|
||||
|
||||
// 설정값 (저장된 값이 없으면 빈 배열/기본값)
|
||||
$hasSettings = $stockSettings->isNotEmpty();
|
||||
$stockItemTypes = $stockSettings->get('stock_item_types')?->setting_value ?? [];
|
||||
$defaultSafetyStock = $stockSettings->get('default_safety_stock')?->setting_value ?? 10;
|
||||
$lowStockAlert = $stockSettings->get('low_stock_alert')?->setting_value ?? true;
|
||||
|
||||
return view('tenant-settings.index', [
|
||||
'tenant' => $tenant,
|
||||
'hasSettings' => $hasSettings,
|
||||
'itemTypeLabels' => $itemTypeLabels,
|
||||
'stockItemTypes' => $stockItemTypes,
|
||||
'defaultSafetyStock' => $defaultSafetyStock,
|
||||
'lowStockAlert' => $lowStockAlert,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 설정 저장
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
if (! $tenantId) {
|
||||
return redirect()->route('tenant-settings.index')
|
||||
->with('error', '테넌트를 먼저 선택해주세요.');
|
||||
}
|
||||
|
||||
$userId = Auth::id();
|
||||
|
||||
// 유효한 품목유형 목록 조회
|
||||
$validItemTypes = array_keys(CommonCode::getItemTypes($tenantId));
|
||||
|
||||
$validated = $request->validate([
|
||||
'stock_item_types' => 'required|array|min:1',
|
||||
'stock_item_types.*' => 'string|in:'.implode(',', $validItemTypes),
|
||||
'default_safety_stock' => 'required|integer|min:0|max:9999',
|
||||
'low_stock_alert' => 'nullable|boolean',
|
||||
]);
|
||||
|
||||
// 재고관리 품목유형 저장
|
||||
TenantSetting::withoutGlobalScopes()->updateOrCreate(
|
||||
[
|
||||
'tenant_id' => $tenantId,
|
||||
'setting_group' => 'stock',
|
||||
'setting_key' => 'stock_item_types',
|
||||
],
|
||||
[
|
||||
'setting_value' => $validated['stock_item_types'],
|
||||
'description' => '재고관리 대상 품목유형',
|
||||
'updated_by' => $userId,
|
||||
]
|
||||
);
|
||||
|
||||
// 기본 안전재고 저장
|
||||
TenantSetting::withoutGlobalScopes()->updateOrCreate(
|
||||
[
|
||||
'tenant_id' => $tenantId,
|
||||
'setting_group' => 'stock',
|
||||
'setting_key' => 'default_safety_stock',
|
||||
],
|
||||
[
|
||||
'setting_value' => (int) $validated['default_safety_stock'],
|
||||
'description' => '안전재고 기본값',
|
||||
'updated_by' => $userId,
|
||||
]
|
||||
);
|
||||
|
||||
// 재고부족 알림 저장
|
||||
TenantSetting::withoutGlobalScopes()->updateOrCreate(
|
||||
[
|
||||
'tenant_id' => $tenantId,
|
||||
'setting_group' => 'stock',
|
||||
'setting_key' => 'low_stock_alert',
|
||||
],
|
||||
[
|
||||
'setting_value' => isset($validated['low_stock_alert']) && $validated['low_stock_alert'],
|
||||
'description' => '재고부족 알림 활성화',
|
||||
'updated_by' => $userId,
|
||||
]
|
||||
);
|
||||
|
||||
return redirect()->route('tenant-settings.index')
|
||||
->with('success', '설정이 저장되었습니다.');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user