diff --git a/app/Http/Controllers/Api/Admin/DocumentApiController.php b/app/Http/Controllers/Api/Admin/DocumentApiController.php new file mode 100644 index 00000000..149c393b --- /dev/null +++ b/app/Http/Controllers/Api/Admin/DocumentApiController.php @@ -0,0 +1,226 @@ +where('tenant_id', $tenantId) + ->orderBy('created_at', 'desc'); + + // 상태 필터 + if ($request->filled('status')) { + $query->where('status', $request->status); + } + + // 템플릿 필터 + if ($request->filled('template_id')) { + $query->where('template_id', $request->template_id); + } + + // 검색 + if ($request->filled('search')) { + $search = $request->search; + $query->where(function ($q) use ($search) { + $q->where('document_no', 'like', "%{$search}%") + ->orWhere('title', 'like', "%{$search}%"); + }); + } + + $documents = $query->paginate($request->input('per_page', 15)); + + return response()->json($documents); + } + + /** + * 문서 상세 조회 + */ + public function show(int $id): JsonResponse + { + $tenantId = session('selected_tenant_id'); + + $document = Document::with([ + 'template.approvalLines', + 'template.basicFields', + 'template.sections.items', + 'template.columns', + 'approvals.user', + 'data', + 'attachments.file', + 'creator', + 'updater', + ])->where('tenant_id', $tenantId)->findOrFail($id); + + return response()->json([ + 'success' => true, + 'data' => $document, + ]); + } + + /** + * 문서 생성 + */ + public function store(Request $request): JsonResponse + { + $tenantId = session('selected_tenant_id'); + $userId = auth()->id(); + + $request->validate([ + 'template_id' => 'required|exists:document_templates,id', + 'title' => 'required|string|max:255', + 'data' => 'nullable|array', + 'data.*.field_key' => 'required|string', + 'data.*.field_value' => 'nullable|string', + ]); + + // 문서 번호 생성 + $documentNo = $this->generateDocumentNo($tenantId, $request->template_id); + + $document = Document::create([ + 'tenant_id' => $tenantId, + 'template_id' => $request->template_id, + 'document_no' => $documentNo, + 'title' => $request->title, + 'status' => Document::STATUS_DRAFT, + 'created_by' => $userId, + 'updated_by' => $userId, + ]); + + // 문서 데이터 저장 + if ($request->filled('data')) { + foreach ($request->data as $item) { + if (! empty($item['field_value'])) { + DocumentData::create([ + 'document_id' => $document->id, + 'field_key' => $item['field_key'], + 'field_value' => $item['field_value'], + ]); + } + } + } + + return response()->json([ + 'success' => true, + 'message' => '문서가 저장되었습니다.', + 'data' => $document->fresh(['template', 'data']), + ], 201); + } + + /** + * 문서 수정 + */ + public function update(int $id, Request $request): JsonResponse + { + $tenantId = session('selected_tenant_id'); + $userId = auth()->id(); + + $document = Document::where('tenant_id', $tenantId)->findOrFail($id); + + // 작성중 또는 반려 상태에서만 수정 가능 + if (! in_array($document->status, [Document::STATUS_DRAFT, Document::STATUS_REJECTED])) { + return response()->json([ + 'success' => false, + 'message' => '현재 상태에서는 수정할 수 없습니다.', + ], 422); + } + + $request->validate([ + 'title' => 'sometimes|required|string|max:255', + 'data' => 'nullable|array', + 'data.*.field_key' => 'required|string', + 'data.*.field_value' => 'nullable|string', + ]); + + $document->update([ + 'title' => $request->input('title', $document->title), + 'updated_by' => $userId, + ]); + + // 문서 데이터 업데이트 + if ($request->has('data')) { + // 기존 데이터 삭제 + $document->data()->delete(); + + // 새 데이터 저장 + foreach ($request->data as $item) { + if (! empty($item['field_value'])) { + DocumentData::create([ + 'document_id' => $document->id, + 'field_key' => $item['field_key'], + 'field_value' => $item['field_value'], + ]); + } + } + } + + return response()->json([ + 'success' => true, + 'message' => '문서가 수정되었습니다.', + 'data' => $document->fresh(['template', 'data']), + ]); + } + + /** + * 문서 삭제 (소프트 삭제) + */ + public function destroy(int $id): JsonResponse + { + $tenantId = session('selected_tenant_id'); + + $document = Document::where('tenant_id', $tenantId)->findOrFail($id); + + // 작성중 상태에서만 삭제 가능 + if ($document->status !== Document::STATUS_DRAFT) { + return response()->json([ + 'success' => false, + 'message' => '작성중 상태의 문서만 삭제할 수 있습니다.', + ], 422); + } + + $document->delete(); + + return response()->json([ + 'success' => true, + 'message' => '문서가 삭제되었습니다.', + ]); + } + + /** + * 문서 번호 생성 + */ + private function generateDocumentNo(int $tenantId, int $templateId): string + { + $prefix = 'DOC'; + $date = now()->format('Ymd'); + + $lastDocument = Document::where('tenant_id', $tenantId) + ->where('template_id', $templateId) + ->whereDate('created_at', now()->toDateString()) + ->orderBy('id', 'desc') + ->first(); + + $sequence = 1; + if ($lastDocument) { + // 마지막 문서 번호에서 시퀀스 추출 + $parts = explode('-', $lastDocument->document_no); + if (count($parts) >= 3) { + $sequence = (int) end($parts) + 1; + } + } + + return sprintf('%s-%s-%04d', $prefix, $date, $sequence); + } +} diff --git a/app/Http/Controllers/CategorySyncController.php b/app/Http/Controllers/CategorySyncController.php new file mode 100644 index 00000000..16eab094 --- /dev/null +++ b/app/Http/Controllers/CategorySyncController.php @@ -0,0 +1,502 @@ +where('tenant_id', $this->getTenantId()) + ->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('categories.sync.index')); + } + + $environments = $this->getEnvironments(); + $selectedEnv = $request->get('env', 'dev'); + $selectedType = $request->get('type', 'global'); // global or tenant + + // 로컬 카테고리 조회 (타입 필터 적용) + $localCategories = $this->getCategoryList($selectedType); + + // 원격 카테고리 조회 + $remoteCategories = []; + $remoteError = null; + + if (! empty($environments[$selectedEnv]['url'])) { + try { + $remoteCategories = $this->fetchRemoteCategories($environments[$selectedEnv], $selectedType); + } catch (\Exception $e) { + $remoteError = $e->getMessage(); + } + } + + // 차이점 계산 + $diff = $this->calculateDiff($localCategories, $remoteCategories); + + return view('categories.sync', [ + 'environments' => $environments, + 'selectedEnv' => $selectedEnv, + 'selectedType' => $selectedType, + 'localCategories' => $localCategories, + 'remoteCategories' => $remoteCategories, + 'remoteError' => $remoteError, + 'diff' => $diff, + ]); + } + + /** + * 카테고리 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); + } + + $type = $request->get('type', 'all'); // global, tenant, or all + $categories = $this->getCategoryList($type); + + return response()->json([ + 'success' => true, + 'environment' => config('app.env'), + 'exported_at' => now()->toIso8601String(), + 'categories' => $categories, + ]); + } + + /** + * 카테고리 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([ + 'categories' => 'required|array', + 'categories.*.is_global' => 'required|boolean', + 'categories.*.tenant_id' => 'nullable|integer', + 'categories.*.code_group' => 'required|string|max:50', + 'categories.*.code' => 'required|string|max:50', + 'categories.*.name' => 'required|string|max:100', + 'categories.*.parent_code' => 'nullable|string|max:50', + 'categories.*.sort_order' => 'nullable|integer', + 'categories.*.description' => 'nullable|string', + 'categories.*.is_active' => 'nullable|boolean', + ]); + + $imported = 0; + $skipped = 0; + + foreach ($validated['categories'] as $catData) { + if ($catData['is_global']) { + // 글로벌 카테고리 + $exists = GlobalCategory::where('code_group', $catData['code_group']) + ->where('code', $catData['code']) + ->exists(); + + if ($exists) { + $skipped++; + continue; + } + + // 부모 찾기 + $parentId = null; + if (! empty($catData['parent_code'])) { + $parent = GlobalCategory::where('code_group', $catData['code_group']) + ->where('code', $catData['parent_code']) + ->first(); + $parentId = $parent?->id; + } + + GlobalCategory::create([ + 'parent_id' => $parentId, + 'code_group' => $catData['code_group'], + 'code' => $catData['code'], + 'name' => $catData['name'], + 'sort_order' => $catData['sort_order'] ?? 0, + 'description' => $catData['description'] ?? null, + 'is_active' => $catData['is_active'] ?? true, + ]); + } else { + // 테넌트 카테고리 + $tenantId = $catData['tenant_id'] ?? $this->getTenantId(); + + $exists = Category::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->where('code_group', $catData['code_group']) + ->where('code', $catData['code']) + ->exists(); + + if ($exists) { + $skipped++; + continue; + } + + // 부모 찾기 + $parentId = null; + if (! empty($catData['parent_code'])) { + $parent = Category::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->where('code_group', $catData['code_group']) + ->where('code', $catData['parent_code']) + ->first(); + $parentId = $parent?->id; + } + + Category::create([ + 'tenant_id' => $tenantId, + 'parent_id' => $parentId, + 'code_group' => $catData['code_group'], + 'code' => $catData['code'], + 'name' => $catData['name'], + 'sort_order' => $catData['sort_order'] ?? 0, + 'description' => $catData['description'] ?? null, + 'is_active' => $catData['is_active'] ?? true, + ]); + } + $imported++; + } + + return response()->json([ + 'success' => true, + 'message' => "{$imported}개 카테고리가 동기화되었습니다." . ($skipped > 0 ? " ({$skipped}개 스킵)" : ''), + 'imported' => $imported, + 'skipped' => $skipped, + ]); + } + + /** + * Push (로컬 → 원격) + */ + public function push(Request $request): JsonResponse + { + $validated = $request->validate([ + 'env' => 'required|string|in:dev,prod', + 'type' => 'required|string|in:global,tenant', + 'category_keys' => 'required|array|min:1', + 'category_keys.*' => 'string', + ]); + + $environments = $this->getEnvironments(); + $env = $environments[$validated['env']] ?? null; + + if (! $env || empty($env['url'])) { + return response()->json(['error' => '환경 설정이 없습니다.'], 400); + } + + // 선택된 카테고리 조회 (타입 필터 적용) + $localCategories = $this->getCategoryList($validated['type']); + $selectedCategories = array_filter($localCategories, function ($cat) use ($validated) { + $key = $this->makeCategoryKey($cat); + return in_array($key, $validated['category_keys']); + }); + + if (empty($selectedCategories)) { + return response()->json(['error' => '선택된 카테고리가 없습니다.'], 400); + } + + // 원격 서버로 전송 + try { + $response = Http::withHeaders([ + 'X-Menu-Sync-Key' => $env['api_key'], + 'Accept' => 'application/json', + ])->post(rtrim($env['url'], '/') . '/category-sync/import', [ + 'categories' => array_values($selectedCategories), + ]); + + if ($response->successful()) { + return response()->json([ + 'success' => true, + 'message' => $response->json('message', '동기화 완료'), + 'imported' => $response->json('imported', 0), + 'skipped' => $response->json('skipped', 0), + ]); + } + + 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', + 'type' => 'required|string|in:global,tenant', + 'category_keys' => 'required|array|min:1', + 'category_keys.*' => 'string', + ]); + + $environments = $this->getEnvironments(); + $env = $environments[$validated['env']] ?? null; + + if (! $env || empty($env['url'])) { + return response()->json(['error' => '환경 설정이 없습니다.'], 400); + } + + // 원격 카테고리 조회 (타입 필터 적용) + try { + $remoteCategories = $this->fetchRemoteCategories($env, $validated['type']); + } catch (\Exception $e) { + return response()->json(['error' => $e->getMessage()], 500); + } + + // 선택된 카테고리만 필터링 + $selectedCategories = array_filter($remoteCategories, function ($cat) use ($validated) { + $key = $this->makeCategoryKey($cat); + return in_array($key, $validated['category_keys']); + }); + + if (empty($selectedCategories)) { + return response()->json(['error' => '선택된 카테고리를 찾을 수 없습니다.'], 400); + } + + // 로컬에 Import + $imported = 0; + $skipped = 0; + + foreach ($selectedCategories as $catData) { + if ($catData['is_global']) { + $exists = GlobalCategory::where('code_group', $catData['code_group']) + ->where('code', $catData['code']) + ->exists(); + + if ($exists) { + $skipped++; + continue; + } + + $parentId = null; + if (! empty($catData['parent_code'])) { + $parent = GlobalCategory::where('code_group', $catData['code_group']) + ->where('code', $catData['parent_code']) + ->first(); + $parentId = $parent?->id; + } + + GlobalCategory::create([ + 'parent_id' => $parentId, + 'code_group' => $catData['code_group'], + 'code' => $catData['code'], + 'name' => $catData['name'], + 'sort_order' => $catData['sort_order'] ?? 0, + 'description' => $catData['description'] ?? null, + 'is_active' => $catData['is_active'] ?? true, + ]); + } else { + $tenantId = $this->getTenantId(); + + $exists = Category::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->where('code_group', $catData['code_group']) + ->where('code', $catData['code']) + ->exists(); + + if ($exists) { + $skipped++; + continue; + } + + $parentId = null; + if (! empty($catData['parent_code'])) { + $parent = Category::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->where('code_group', $catData['code_group']) + ->where('code', $catData['parent_code']) + ->first(); + $parentId = $parent?->id; + } + + Category::create([ + 'tenant_id' => $tenantId, + 'parent_id' => $parentId, + 'code_group' => $catData['code_group'], + 'code' => $catData['code'], + 'name' => $catData['name'], + 'sort_order' => $catData['sort_order'] ?? 0, + 'description' => $catData['description'] ?? null, + 'is_active' => $catData['is_active'] ?? true, + ]); + } + $imported++; + } + + return response()->json([ + 'success' => true, + 'message' => "{$imported}개 카테고리가 동기화되었습니다." . ($skipped > 0 ? " ({$skipped}개 스킵)" : ''), + 'imported' => $imported, + 'skipped' => $skipped, + ]); + } + + /** + * 카테고리 목록 조회 + * @param string $type 'global', 'tenant', or 'all' + */ + private function getCategoryList(string $type = 'all'): array + { + $tenantId = $this->getTenantId(); + $categories = []; + + // 글로벌 카테고리 + if ($type === 'global' || $type === 'all') { + $globalCategories = GlobalCategory::whereNull('deleted_at') + ->orderBy('code_group') + ->orderBy('sort_order') + ->get(); + + foreach ($globalCategories as $cat) { + $parentCode = null; + if ($cat->parent_id) { + $parent = GlobalCategory::find($cat->parent_id); + $parentCode = $parent?->code; + } + + $categories[] = [ + 'is_global' => true, + 'tenant_id' => null, + 'code_group' => $cat->code_group, + 'code' => $cat->code, + 'name' => $cat->name, + 'parent_code' => $parentCode, + 'sort_order' => $cat->sort_order, + 'description' => $cat->description, + 'is_active' => $cat->is_active, + ]; + } + } + + // 테넌트 카테고리 + if ($type === 'tenant' || $type === 'all') { + $tenantCategories = Category::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->whereNull('deleted_at') + ->orderBy('code_group') + ->orderBy('sort_order') + ->get(); + + foreach ($tenantCategories as $cat) { + $parentCode = null; + if ($cat->parent_id) { + $parent = Category::withoutGlobalScopes()->find($cat->parent_id); + $parentCode = $parent?->code; + } + + $categories[] = [ + 'is_global' => false, + 'tenant_id' => $cat->tenant_id, + 'code_group' => $cat->code_group, + 'code' => $cat->code, + 'name' => $cat->name, + 'parent_code' => $parentCode, + 'sort_order' => $cat->sort_order, + 'description' => $cat->description, + 'is_active' => $cat->is_active, + ]; + } + } + + return $categories; + } + + /** + * 원격 카테고리 조회 + */ + private function fetchRemoteCategories(array $env, string $type = 'all'): array + { + $response = Http::withHeaders([ + 'X-Menu-Sync-Key' => $env['api_key'], + 'Accept' => 'application/json', + ])->timeout(10)->get(rtrim($env['url'], '/') . '/category-sync/export', [ + 'type' => $type, + ]); + + if (! $response->successful()) { + throw new \Exception('API 오류: HTTP ' . $response->status()); + } + + $data = $response->json(); + if (! isset($data['categories'])) { + throw new \Exception('잘못된 응답 형식'); + } + + return $data['categories']; + } + + /** + * 카테고리 키 생성 (유니크 식별자) + */ + private function makeCategoryKey(array $cat): string + { + $typePart = $cat['is_global'] ? 'global' : "tenant:{$cat['tenant_id']}"; + return "{$typePart}:{$cat['code_group']}:{$cat['code']}"; + } + + /** + * 차이점 계산 + */ + private function calculateDiff(array $localCategories, array $remoteCategories): array + { + $localKeys = array_map(fn($c) => $this->makeCategoryKey($c), $localCategories); + $remoteKeys = array_map(fn($c) => $this->makeCategoryKey($c), $remoteCategories); + + return [ + 'local_only' => array_values(array_diff($localKeys, $remoteKeys)), + 'remote_only' => array_values(array_diff($remoteKeys, $localKeys)), + 'both' => array_values(array_intersect($localKeys, $remoteKeys)), + ]; + } +} \ No newline at end of file diff --git a/app/Http/Controllers/CommonCodeController.php b/app/Http/Controllers/CommonCodeController.php index 236f6e04..47d6d33e 100644 --- a/app/Http/Controllers/CommonCodeController.php +++ b/app/Http/Controllers/CommonCodeController.php @@ -178,19 +178,25 @@ public function update(Request $request, int $id): RedirectResponse|JsonResponse 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', '글로벌 코드는 본사만 수정할 수 있습니다.'); - } + // 권한 체크: 슈퍼관리자는 모든 코드 수정 가능 + $isSuperAdmin = auth()->user()?->isSuperAdmin() ?? false; - if ($code->tenant_id !== null && $code->tenant_id !== $tenantId) { - if ($request->ajax()) { - return response()->json(['error' => '다른 테넌트의 코드는 수정할 수 없습니다.'], 403); + if (! $isSuperAdmin) { + // 글로벌 코드는 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', '다른 테넌트의 코드는 수정할 수 없습니다.'); } - return redirect()->back()->with('error', '다른 테넌트의 코드는 수정할 수 없습니다.'); } $validated = $request->validate([ @@ -244,13 +250,17 @@ public function toggle(Request $request, int $id): JsonResponse return response()->json(['error' => '코드를 찾을 수 없습니다.'], 404); } - // 권한 체크 - if ($code->tenant_id === null && ! $isHQ) { - return response()->json(['error' => '글로벌 코드는 본사만 수정할 수 있습니다.'], 403); - } + // 권한 체크: 슈퍼관리자는 모든 코드 수정 가능 + $isSuperAdmin = auth()->user()?->isSuperAdmin() ?? false; - if ($code->tenant_id !== null && $code->tenant_id !== $tenantId) { - return response()->json(['error' => '다른 테넌트의 코드는 수정할 수 없습니다.'], 403); + if (! $isSuperAdmin) { + 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; @@ -438,20 +448,25 @@ public function destroy(Request $request, int $id): RedirectResponse|JsonRespons 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', '글로벌 코드는 본사만 삭제할 수 있습니다.'); - } + // 권한 체크: 슈퍼관리자는 모든 코드 삭제 가능 + $isSuperAdmin = auth()->user()?->isSuperAdmin() ?? false; - // 다른 테넌트 코드 삭제 불가 - if ($code->tenant_id !== null && $code->tenant_id !== $tenantId) { - if ($request->ajax()) { - return response()->json(['error' => '다른 테넌트의 코드는 삭제할 수 없습니다.'], 403); + if (! $isSuperAdmin) { + // 글로벌 코드 삭제는 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', '다른 테넌트의 코드는 삭제할 수 없습니다.'); } - return redirect()->back()->with('error', '다른 테넌트의 코드는 삭제할 수 없습니다.'); } $codeGroup = $code->code_group; diff --git a/app/Http/Controllers/CommonCodeSyncController.php b/app/Http/Controllers/CommonCodeSyncController.php new file mode 100644 index 00000000..3623bade --- /dev/null +++ b/app/Http/Controllers/CommonCodeSyncController.php @@ -0,0 +1,399 @@ +where('tenant_id', $this->getTenantId()) + ->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('common-codes.sync.index')); + } + + $environments = $this->getEnvironments(); + $selectedEnv = $request->get('env', 'dev'); + $selectedType = $request->get('type', 'global'); // global or tenant + + // 로컬 코드 조회 (타입 필터 적용) + $localCodes = $this->getCodeList($selectedType); + + // 원격 코드 조회 + $remoteCodes = []; + $remoteError = null; + + if (! empty($environments[$selectedEnv]['url'])) { + try { + $remoteCodes = $this->fetchRemoteCodes($environments[$selectedEnv], $selectedType); + } catch (\Exception $e) { + $remoteError = $e->getMessage(); + } + } + + // 차이점 계산 + $diff = $this->calculateDiff($localCodes, $remoteCodes); + + return view('common-codes.sync', [ + 'environments' => $environments, + 'selectedEnv' => $selectedEnv, + 'selectedType' => $selectedType, + 'localCodes' => $localCodes, + 'remoteCodes' => $remoteCodes, + 'remoteError' => $remoteError, + 'diff' => $diff, + ]); + } + + /** + * 공통코드 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); + } + + $type = $request->get('type', 'all'); // global, tenant, or all + $codes = $this->getCodeList($type); + + return response()->json([ + 'success' => true, + 'environment' => config('app.env'), + 'exported_at' => now()->toIso8601String(), + 'codes' => $codes, + ]); + } + + /** + * 공통코드 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([ + 'codes' => 'required|array', + 'codes.*.tenant_id' => 'nullable|integer', + 'codes.*.code_group' => 'required|string|max:50', + 'codes.*.code' => 'required|string|max:50', + 'codes.*.name' => 'required|string|max:100', + 'codes.*.sort_order' => 'nullable|integer', + 'codes.*.attributes' => 'nullable|array', + 'codes.*.is_active' => 'nullable|boolean', + ]); + + $imported = 0; + $skipped = 0; + + foreach ($validated['codes'] as $codeData) { + // 동일 코드 존재 확인 + $exists = CommonCode::query() + ->where('tenant_id', $codeData['tenant_id'] ?? null) + ->where('code_group', $codeData['code_group']) + ->where('code', $codeData['code']) + ->exists(); + + if ($exists) { + $skipped++; + continue; + } + + CommonCode::create([ + 'tenant_id' => $codeData['tenant_id'] ?? null, + 'code_group' => $codeData['code_group'], + 'code' => $codeData['code'], + 'name' => $codeData['name'], + 'sort_order' => $codeData['sort_order'] ?? 0, + 'attributes' => $codeData['attributes'] ?? null, + 'is_active' => $codeData['is_active'] ?? true, + ]); + $imported++; + } + + return response()->json([ + 'success' => true, + 'message' => "{$imported}개 코드가 동기화되었습니다." . ($skipped > 0 ? " ({$skipped}개 스킵)" : ''), + 'imported' => $imported, + 'skipped' => $skipped, + ]); + } + + /** + * Push (로컬 → 원격) + */ + public function push(Request $request): JsonResponse + { + $validated = $request->validate([ + 'env' => 'required|string|in:dev,prod', + 'type' => 'required|string|in:global,tenant', + 'code_keys' => 'required|array|min:1', + 'code_keys.*' => 'string', + ]); + + $environments = $this->getEnvironments(); + $env = $environments[$validated['env']] ?? null; + + if (! $env || empty($env['url'])) { + return response()->json(['error' => '환경 설정이 없습니다.'], 400); + } + + // 선택된 코드 조회 (타입 필터 적용) + $localCodes = $this->getCodeList($validated['type']); + $selectedCodes = array_filter($localCodes, function ($code) use ($validated) { + $key = $this->makeCodeKey($code); + return in_array($key, $validated['code_keys']); + }); + + if (empty($selectedCodes)) { + return response()->json(['error' => '선택된 코드가 없습니다.'], 400); + } + + // 원격 서버로 전송 + try { + $response = Http::withHeaders([ + 'X-Menu-Sync-Key' => $env['api_key'], + 'Accept' => 'application/json', + ])->post(rtrim($env['url'], '/') . '/common-code-sync/import', [ + 'codes' => array_values($selectedCodes), + ]); + + if ($response->successful()) { + return response()->json([ + 'success' => true, + 'message' => $response->json('message', '동기화 완료'), + 'imported' => $response->json('imported', 0), + 'skipped' => $response->json('skipped', 0), + ]); + } + + 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', + 'type' => 'required|string|in:global,tenant', + 'code_keys' => 'required|array|min:1', + 'code_keys.*' => 'string', + ]); + + $environments = $this->getEnvironments(); + $env = $environments[$validated['env']] ?? null; + + if (! $env || empty($env['url'])) { + return response()->json(['error' => '환경 설정이 없습니다.'], 400); + } + + // 원격 코드 조회 (타입 필터 적용) + try { + $remoteCodes = $this->fetchRemoteCodes($env, $validated['type']); + } catch (\Exception $e) { + return response()->json(['error' => $e->getMessage()], 500); + } + + // 선택된 코드만 필터링 + $selectedCodes = array_filter($remoteCodes, function ($code) use ($validated) { + $key = $this->makeCodeKey($code); + return in_array($key, $validated['code_keys']); + }); + + if (empty($selectedCodes)) { + return response()->json(['error' => '선택된 코드를 찾을 수 없습니다.'], 400); + } + + // 로컬에 Import + $imported = 0; + $skipped = 0; + + foreach ($selectedCodes as $codeData) { + // 동일 코드 존재 확인 + $exists = CommonCode::query() + ->where('tenant_id', $codeData['tenant_id'] ?? null) + ->where('code_group', $codeData['code_group']) + ->where('code', $codeData['code']) + ->exists(); + + if ($exists) { + $skipped++; + continue; + } + + CommonCode::create([ + 'tenant_id' => $codeData['tenant_id'] ?? null, + 'code_group' => $codeData['code_group'], + 'code' => $codeData['code'], + 'name' => $codeData['name'], + 'sort_order' => $codeData['sort_order'] ?? 0, + 'attributes' => $codeData['attributes'] ?? null, + 'is_active' => $codeData['is_active'] ?? true, + ]); + $imported++; + } + + return response()->json([ + 'success' => true, + 'message' => "{$imported}개 코드가 동기화되었습니다." . ($skipped > 0 ? " ({$skipped}개 스킵)" : ''), + 'imported' => $imported, + 'skipped' => $skipped, + ]); + } + + /** + * 코드 목록 조회 + * @param string $type 'global', 'tenant', or 'all' + */ + private function getCodeList(string $type = 'all'): array + { + $tenantId = $this->getTenantId(); + $codes = []; + + // 글로벌 코드 (tenant_id IS NULL) + if ($type === 'global' || $type === 'all') { + $globalCodes = CommonCode::query() + ->whereNull('tenant_id') + ->orderBy('code_group') + ->orderBy('sort_order') + ->get(); + + foreach ($globalCodes as $code) { + $codes[] = [ + 'tenant_id' => null, + 'code_group' => $code->code_group, + 'code' => $code->code, + 'name' => $code->name, + 'sort_order' => $code->sort_order, + 'attributes' => $code->attributes, + 'is_active' => $code->is_active, + 'is_global' => true, + ]; + } + } + + // 테넌트 코드 + if ($type === 'tenant' || $type === 'all') { + $tenantCodes = CommonCode::query() + ->where('tenant_id', $tenantId) + ->orderBy('code_group') + ->orderBy('sort_order') + ->get(); + + foreach ($tenantCodes as $code) { + $codes[] = [ + 'tenant_id' => $code->tenant_id, + 'code_group' => $code->code_group, + 'code' => $code->code, + 'name' => $code->name, + 'sort_order' => $code->sort_order, + 'attributes' => $code->attributes, + 'is_active' => $code->is_active, + 'is_global' => false, + ]; + } + } + + return $codes; + } + + /** + * 원격 코드 조회 + */ + private function fetchRemoteCodes(array $env, string $type = 'all'): array + { + $response = Http::withHeaders([ + 'X-Menu-Sync-Key' => $env['api_key'], + 'Accept' => 'application/json', + ])->timeout(10)->get(rtrim($env['url'], '/') . '/common-code-sync/export', [ + 'type' => $type, + ]); + + if (! $response->successful()) { + throw new \Exception('API 오류: HTTP ' . $response->status()); + } + + $data = $response->json(); + if (! isset($data['codes'])) { + throw new \Exception('잘못된 응답 형식'); + } + + return $data['codes']; + } + + /** + * 코드 키 생성 (유니크 식별자) + */ + private function makeCodeKey(array $code): string + { + $tenantPart = $code['tenant_id'] ?? 'global'; + return "{$tenantPart}:{$code['code_group']}:{$code['code']}"; + } + + /** + * 차이점 계산 + */ + private function calculateDiff(array $localCodes, array $remoteCodes): array + { + $localKeys = array_map(fn($c) => $this->makeCodeKey($c), $localCodes); + $remoteKeys = array_map(fn($c) => $this->makeCodeKey($c), $remoteCodes); + + return [ + 'local_only' => array_values(array_diff($localKeys, $remoteKeys)), + 'remote_only' => array_values(array_diff($remoteKeys, $localKeys)), + 'both' => array_values(array_intersect($localKeys, $remoteKeys)), + ]; + } +} \ No newline at end of file diff --git a/app/Http/Controllers/DocumentController.php b/app/Http/Controllers/DocumentController.php new file mode 100644 index 00000000..7c3db747 --- /dev/null +++ b/app/Http/Controllers/DocumentController.php @@ -0,0 +1,121 @@ +header('HX-Request')) { + return response('', 200)->header('HX-Redirect', route('documents.index')); + } + + $tenantId = session('selected_tenant_id'); + + // 템플릿 목록 (필터용) + $templates = $tenantId + ? DocumentTemplate::where(function ($q) use ($tenantId) { + $q->whereNull('tenant_id')->orWhere('tenant_id', $tenantId); + })->where('is_active', true)->orderBy('name')->get() + : collect(); + + return view('documents.index', [ + 'templates' => $templates, + 'statuses' => Document::STATUS_LABELS, + ]); + } + + /** + * 문서 생성 페이지 + */ + public function create(Request $request): View + { + $tenantId = session('selected_tenant_id'); + $templateId = $request->query('template_id'); + + // 템플릿 목록 + $templates = $tenantId + ? DocumentTemplate::where(function ($q) use ($tenantId) { + $q->whereNull('tenant_id')->orWhere('tenant_id', $tenantId); + })->where('is_active', true)->orderBy('name')->get() + : collect(); + + // 선택된 템플릿 + $template = $templateId + ? DocumentTemplate::with(['approvalLines', 'basicFields', 'sections.items', 'columns'])->find($templateId) + : null; + + return view('documents.edit', [ + 'document' => null, + 'template' => $template, + 'templates' => $templates, + 'isCreate' => true, + ]); + } + + /** + * 문서 수정 페이지 + */ + public function edit(int $id): View + { + $tenantId = session('selected_tenant_id'); + + $document = Document::with([ + 'template.approvalLines', + 'template.basicFields', + 'template.sections.items', + 'template.columns', + 'approvals.user', + 'data', + 'attachments.file', + 'creator', + ])->where('tenant_id', $tenantId)->findOrFail($id); + + // 템플릿 목록 (변경용) + $templates = $tenantId + ? DocumentTemplate::where(function ($q) use ($tenantId) { + $q->whereNull('tenant_id')->orWhere('tenant_id', $tenantId); + })->where('is_active', true)->orderBy('name')->get() + : collect(); + + return view('documents.edit', [ + 'document' => $document, + 'template' => $document->template, + 'templates' => $templates, + 'isCreate' => false, + ]); + } + + /** + * 문서 상세 페이지 (읽기 전용) + */ + public function show(int $id): View + { + $tenantId = session('selected_tenant_id'); + + $document = Document::with([ + 'template.approvalLines', + 'template.basicFields', + 'template.sections.items', + 'template.columns', + 'approvals.user', + 'data', + 'attachments.file', + 'creator', + 'updater', + ])->where('tenant_id', $tenantId)->findOrFail($id); + + return view('documents.show', [ + 'document' => $document, + ]); + } +} diff --git a/app/Http/Controllers/DocumentTemplateController.php b/app/Http/Controllers/DocumentTemplateController.php index 22d18f54..613402b5 100644 --- a/app/Http/Controllers/DocumentTemplateController.php +++ b/app/Http/Controllers/DocumentTemplateController.php @@ -3,7 +3,7 @@ namespace App\Http\Controllers; use App\Models\DocumentTemplate; -use App\Models\Products\CommonCode; +use App\Models\Tenants\Tenant; use Illuminate\Http\Request; use Illuminate\View\View; @@ -15,7 +15,7 @@ class DocumentTemplateController extends Controller public function index(Request $request): View { return view('document-templates.index', [ - 'documentTypes' => $this->getDocumentTypes(), + 'categories' => $this->getCategories(), ]); } @@ -28,7 +28,8 @@ public function create(): View 'template' => null, 'templateData' => null, 'isCreate' => true, - 'documentTypes' => $this->getDocumentTypes(), + 'categories' => $this->getCategories(), + 'tenant' => $this->getCurrentTenant(), ]); } @@ -51,28 +52,40 @@ public function edit(int $id): View 'template' => $template, 'templateData' => $templateData, 'isCreate' => false, - 'documentTypes' => $this->getDocumentTypes(), + 'categories' => $this->getCategories(), + 'tenant' => $this->getCurrentTenant(), ]); } /** - * 문서분류 목록 조회 (글로벌 + 테넌트) + * 현재 선택된 테넌트 조회 */ - private function getDocumentTypes(): array + private function getCurrentTenant(): ?Tenant { $tenantId = session('selected_tenant_id'); - return CommonCode::query() + return $tenantId ? Tenant::find($tenantId) : null; + } + + /** + * 문서분류 목록 조회 (글로벌 + 테넌트, 기존 데이터에서 group by) + */ + private function getCategories(): array + { + $tenantId = session('selected_tenant_id'); + + return DocumentTemplate::query() ->where(function ($query) use ($tenantId) { $query->whereNull('tenant_id'); if ($tenantId) { $query->orWhere('tenant_id', $tenantId); } }) - ->where('code_group', 'document_type') - ->where('is_active', true) - ->orderBy('sort_order') - ->pluck('name', 'code') + ->whereNotNull('category') + ->where('category', '!=', '') + ->distinct() + ->orderBy('category') + ->pluck('category') ->toArray(); } @@ -85,9 +98,6 @@ private function prepareTemplateData(DocumentTemplate $template): array 'name' => $template->name, 'category' => $template->category, 'title' => $template->title, - 'company_name' => $template->company_name, - 'company_address' => $template->company_address, - 'company_contact' => $template->company_contact, 'footer_remark_label' => $template->footer_remark_label, 'footer_judgement_label' => $template->footer_judgement_label, 'footer_judgement_options' => $template->footer_judgement_options, diff --git a/app/Http/Middleware/AutoLoginViaRemember.php b/app/Http/Middleware/AutoLoginViaRemember.php new file mode 100644 index 00000000..f3ea54c1 --- /dev/null +++ b/app/Http/Middleware/AutoLoginViaRemember.php @@ -0,0 +1,82 @@ +belongsToHQ()) { + Auth::logout(); + Log::info('[AutoLoginViaRemember] Non-HQ user rejected', ['user_id' => $user->id]); + return $next($request); + } + + // 활성 상태 확인 + if (!$user->is_active) { + Auth::logout(); + Log::info('[AutoLoginViaRemember] Inactive user rejected', ['user_id' => $user->id]); + return $next($request); + } + + // HQ 테넌트를 기본 선택 + $hqTenant = $user->getHQTenant(); + if ($hqTenant) { + session(['selected_tenant_id' => $hqTenant->id]); + + // API 토큰 재발급 + $this->refreshApiToken($user->id, $hqTenant->id); + } + + Log::info('[AutoLoginViaRemember] Auto login successful', ['user_id' => $user->id]); + } + + return $next($request); + } + + /** + * API 토큰 재발급 + */ + private function refreshApiToken(int $userId, int $tenantId): void + { + try { + $result = $this->apiTokenService->exchangeToken($userId, $tenantId); + + if ($result['success']) { + $this->apiTokenService->storeTokenInSession( + $result['data']['access_token'], + $result['data']['expires_in'] + ); + } + } catch (\Exception $e) { + Log::warning('[AutoLoginViaRemember] API token refresh failed', [ + 'user_id' => $userId, + 'error' => $e->getMessage(), + ]); + } + } +} \ No newline at end of file diff --git a/app/Models/Documents/Document.php b/app/Models/Documents/Document.php new file mode 100644 index 00000000..d6a2bb3e --- /dev/null +++ b/app/Models/Documents/Document.php @@ -0,0 +1,178 @@ + '작성중', + self::STATUS_PENDING => '결재중', + self::STATUS_APPROVED => '승인', + self::STATUS_REJECTED => '반려', + self::STATUS_CANCELLED => '취소', + ]; + + protected $fillable = [ + 'tenant_id', + 'template_id', + 'document_no', + 'title', + 'status', + 'linkable_type', + 'linkable_id', + 'submitted_at', + 'completed_at', + 'created_by', + 'updated_by', + 'deleted_by', + ]; + + protected $casts = [ + 'submitted_at' => 'datetime', + 'completed_at' => 'datetime', + ]; + + protected $attributes = [ + 'status' => self::STATUS_DRAFT, + ]; + + // ========================================================================= + // Relationships + // ========================================================================= + + public function template(): BelongsTo + { + return $this->belongsTo(DocumentTemplate::class, 'template_id'); + } + + public function approvals(): HasMany + { + return $this->hasMany(DocumentApproval::class)->orderBy('step'); + } + + public function data(): HasMany + { + return $this->hasMany(DocumentData::class); + } + + public function attachments(): HasMany + { + return $this->hasMany(DocumentAttachment::class); + } + + public function linkable(): MorphTo + { + return $this->morphTo(); + } + + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + public function updater(): BelongsTo + { + return $this->belongsTo(User::class, 'updated_by'); + } + + // ========================================================================= + // Accessors + // ========================================================================= + + public function getStatusLabelAttribute(): string + { + return self::STATUS_LABELS[$this->status] ?? $this->status; + } + + public function getStatusColorAttribute(): string + { + return match ($this->status) { + self::STATUS_DRAFT => 'gray', + self::STATUS_PENDING => 'yellow', + self::STATUS_APPROVED => 'green', + self::STATUS_REJECTED => 'red', + self::STATUS_CANCELLED => 'gray', + default => 'gray', + }; + } + + // ========================================================================= + // Scopes + // ========================================================================= + + public function scopeStatus($query, string $status) + { + return $query->where('status', $status); + } + + public function scopeDraft($query) + { + return $query->where('status', self::STATUS_DRAFT); + } + + public function scopePending($query) + { + return $query->where('status', self::STATUS_PENDING); + } + + // ========================================================================= + // Helper Methods + // ========================================================================= + + public function isDraft(): bool + { + return $this->status === self::STATUS_DRAFT; + } + + public function isPending(): bool + { + return $this->status === self::STATUS_PENDING; + } + + public function isApproved(): bool + { + return $this->status === self::STATUS_APPROVED; + } + + public function isRejected(): bool + { + return $this->status === self::STATUS_REJECTED; + } + + public function canEdit(): bool + { + return in_array($this->status, [self::STATUS_DRAFT, self::STATUS_REJECTED]); + } +} diff --git a/app/Models/Documents/DocumentApproval.php b/app/Models/Documents/DocumentApproval.php new file mode 100644 index 00000000..a9899c00 --- /dev/null +++ b/app/Models/Documents/DocumentApproval.php @@ -0,0 +1,94 @@ + '대기', + self::STATUS_APPROVED => '승인', + self::STATUS_REJECTED => '반려', + ]; + + protected $fillable = [ + 'document_id', + 'user_id', + 'step', + 'role', + 'status', + 'comment', + 'acted_at', + 'created_by', + 'updated_by', + ]; + + protected $casts = [ + 'step' => 'integer', + 'acted_at' => 'datetime', + ]; + + // ========================================================================= + // Relationships + // ========================================================================= + + public function document(): BelongsTo + { + return $this->belongsTo(Document::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + // ========================================================================= + // Accessors + // ========================================================================= + + public function getStatusLabelAttribute(): string + { + return self::STATUS_LABELS[$this->status] ?? $this->status; + } + + public function getStatusColorAttribute(): string + { + return match ($this->status) { + self::STATUS_PENDING => 'yellow', + self::STATUS_APPROVED => 'green', + self::STATUS_REJECTED => 'red', + default => 'gray', + }; + } + + // ========================================================================= + // Helper Methods + // ========================================================================= + + public function isPending(): bool + { + return $this->status === self::STATUS_PENDING; + } + + public function isApproved(): bool + { + return $this->status === self::STATUS_APPROVED; + } + + public function isRejected(): bool + { + return $this->status === self::STATUS_REJECTED; + } +} diff --git a/app/Models/Documents/DocumentAttachment.php b/app/Models/Documents/DocumentAttachment.php new file mode 100644 index 00000000..0509185a --- /dev/null +++ b/app/Models/Documents/DocumentAttachment.php @@ -0,0 +1,69 @@ + '일반', + self::TYPE_SIGNATURE => '서명', + self::TYPE_IMAGE => '이미지', + self::TYPE_REFERENCE => '참고자료', + ]; + + protected $fillable = [ + 'document_id', + 'file_id', + 'attachment_type', + 'description', + 'created_by', + ]; + + protected $attributes = [ + 'attachment_type' => self::TYPE_GENERAL, + ]; + + // ========================================================================= + // Relationships + // ========================================================================= + + public function document(): BelongsTo + { + return $this->belongsTo(Document::class); + } + + public function file(): BelongsTo + { + return $this->belongsTo(File::class); + } + + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + // ========================================================================= + // Accessors + // ========================================================================= + + public function getTypeLabelAttribute(): string + { + return self::TYPE_LABELS[$this->attachment_type] ?? $this->attachment_type; + } +} diff --git a/app/Models/Documents/DocumentData.php b/app/Models/Documents/DocumentData.php new file mode 100644 index 00000000..6421ac97 --- /dev/null +++ b/app/Models/Documents/DocumentData.php @@ -0,0 +1,47 @@ + 'integer', + ]; + + // ========================================================================= + // Relationships + // ========================================================================= + + public function document(): BelongsTo + { + return $this->belongsTo(Document::class); + } + + // ========================================================================= + // Scopes + // ========================================================================= + + public function scopeForSection($query, int $sectionId) + { + return $query->where('section_id', $sectionId); + } + + public function scopeForField($query, string $fieldKey) + { + return $query->where('field_key', $fieldKey); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index afb18a8f..ff80ee5e 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -24,11 +24,13 @@ // CSRF 토큰 검증 예외 (외부 API 호출용) $middleware->validateCsrfTokens(except: [ 'menu-sync/*', + 'common-code-sync/*', + 'category-sync/*', ]); - // auth 미들웨어 그룹에 HQ 검증 추가 + // web 미들웨어 그룹에 자동 재인증 추가 $middleware->appendToGroup('web', [ - // 기본 web 미들웨어에는 추가하지 않음 (auth에서만 적용) + \App\Http\Middleware\AutoLoginViaRemember::class, ]); }) ->withExceptions(function (Exceptions $exceptions): void { diff --git a/database/seeders/InspectionTemplateSeeder.php b/database/seeders/InspectionTemplateSeeder.php new file mode 100644 index 00000000..3fbdc178 --- /dev/null +++ b/database/seeders/InspectionTemplateSeeder.php @@ -0,0 +1,78 @@ +cleanupExisting($tenantId); + + // 템플릿 생성 + $template = DocumentTemplate::create([ + 'tenant_id' => $tenantId, + 'name' => '철제품 수입검사 성적서', + 'category' => '품질/수입검사', + 'title' => '수입검사 성적서', + 'is_active' => true, + ]); + + // 검사항목 섹션 + $section = DocumentTemplateSection::create([ + 'template_id' => $template->id, + 'title' => '검사 항목', + 'sort_order' => 1, + ]); + + // 검사항목 (React 모달과 동일) + $items = [ + ['item' => '겉모양', 'standard' => '외관 이상 없음', 'method' => '육안'], + ['item' => '두께', 'standard' => 't 1.0', 'method' => '계측'], + ['item' => '폭', 'standard' => 'W 1,000mm', 'method' => '계측'], + ['item' => '길이', 'standard' => 'L 2,000mm', 'method' => '계측'], + ]; + + foreach ($items as $i => $item) { + DocumentTemplateSectionItem::create([ + 'section_id' => $section->id, + 'item' => $item['item'], + 'standard' => $item['standard'], + 'method' => $item['method'], + 'sort_order' => $i + 1, + ]); + } + + $this->command->info("✅ 템플릿 생성 완료 (ID: {$template->id})"); + } + + private function cleanupExisting(int $tenantId): void + { + $existing = DocumentTemplate::where('tenant_id', $tenantId) + ->where('name', '철제품 수입검사 성적서') + ->first(); + + if ($existing) { + DocumentTemplateColumn::where('template_id', $existing->id)->delete(); + $sections = DocumentTemplateSection::where('template_id', $existing->id)->get(); + foreach ($sections as $section) { + DocumentTemplateSectionItem::where('section_id', $section->id)->delete(); + } + DocumentTemplateSection::where('template_id', $existing->id)->delete(); + DocumentTemplateBasicField::where('template_id', $existing->id)->delete(); + DocumentTemplateApprovalLine::where('template_id', $existing->id)->delete(); + $existing->forceDelete(); + } + } +} diff --git a/public/tenant-storage b/public/tenant-storage index 96384d24..2a73345c 120000 --- a/public/tenant-storage +++ b/public/tenant-storage @@ -1 +1 @@ -/var/www/shared-storage/tenants \ No newline at end of file +/var/www/api/storage/app/tenants \ No newline at end of file diff --git a/resources/views/categories/index.blade.php b/resources/views/categories/index.blade.php index 5b511975..cdc37599 100644 --- a/resources/views/categories/index.blade.php +++ b/resources/views/categories/index.blade.php @@ -22,16 +22,25 @@ @endif
- @if($tenant) -