$tenantId, 'session' => session()->all(), ]); if (! $tenantId || $tenantId === 'all') { return view('item-fields.partials.seeding-status', [ 'statuses' => [], 'error' => '테넌트를 선택해주세요.', ]); } $statuses = $this->service->getSeedingStatus($tenantId); Log::info('seedingStatus result', [ 'statuses_count' => count($statuses), 'statuses' => $statuses, ]); return view('item-fields.partials.seeding-status', compact('statuses')); } /** * 단일 테이블 시딩 */ public function seed(Request $request): JsonResponse { $tenantId = session('selected_tenant_id'); if (! $tenantId || $tenantId === 'all') { return response()->json([ 'success' => false, 'message' => '테넌트를 선택해주세요.', ], 400); } $sourceTable = $request->input('source_table'); if (! $sourceTable) { return response()->json([ 'success' => false, 'message' => '소스 테이블을 지정해주세요.', ], 400); } $result = $this->service->seedTable($tenantId, $sourceTable); return response()->json($result); } /** * 전체 테이블 시딩 */ public function seedAll(Request $request): JsonResponse { $tenantId = session('selected_tenant_id'); if (! $tenantId || $tenantId === 'all') { return response()->json([ 'success' => false, 'message' => '테넌트를 선택해주세요.', ], 400); } $result = $this->service->seedAll($tenantId); return response()->json($result); } /** * 단일 테이블 초기화 */ public function reset(Request $request): JsonResponse { $tenantId = session('selected_tenant_id'); if (! $tenantId || $tenantId === 'all') { return response()->json([ 'success' => false, 'message' => '테넌트를 선택해주세요.', ], 400); } $sourceTable = $request->input('source_table'); if (! $sourceTable) { return response()->json([ 'success' => false, 'message' => '소스 테이블을 지정해주세요.', ], 400); } $result = $this->service->resetTable($tenantId, $sourceTable); return response()->json($result); } /** * 전체 테이블 초기화 */ public function resetAll(Request $request): JsonResponse { $tenantId = session('selected_tenant_id'); if (! $tenantId || $tenantId === 'all') { return response()->json([ 'success' => false, 'message' => '테넌트를 선택해주세요.', ], 400); } $result = $this->service->resetAll($tenantId); return response()->json($result); } /** * 필드 목록 (HTMX partial) - 시스템 + 커스텀 */ public function customFields(Request $request): View { $tenantId = session('selected_tenant_id'); $sourceTables = SystemFieldDefinition::getSourceTableOptions(); if (! $tenantId || $tenantId === 'all') { return view('item-fields.partials.custom-fields', [ 'fields' => collect([]), 'sourceTables' => $sourceTables, 'error' => '테넌트를 선택해주세요.', ]); } // getFields() 메서드 사용 (시스템 + 커스텀 모두 조회, 시스템 필드 우선 정렬) $filters = $request->all(); $fields = $this->service->getFields($tenantId, $filters); // per_page 값을 명시적으로 뷰에 전달 (셀렉트박스 selected 상태 유지용) $perPage = !empty($filters['per_page']) ? (int) $filters['per_page'] : 20; return view('item-fields.partials.custom-fields', [ 'fields' => $fields, 'sourceTables' => $sourceTables, 'perPage' => $perPage, ]); } /** * 커스텀 필드 추가 */ public function storeCustomField(Request $request): JsonResponse { $tenantId = session('selected_tenant_id'); if (! $tenantId || $tenantId === 'all') { return response()->json([ 'success' => false, 'message' => '테넌트를 선택해주세요.', ], 400); } $validated = $request->validate([ 'source_table' => 'required|string', 'field_key' => 'required|string|max:100|regex:/^[a-zA-Z0-9_]+$/', 'field_name' => 'required|string|max:255', 'field_type' => 'required|string|in:textbox,number,dropdown,checkbox,date,textarea', 'is_required' => 'nullable|boolean', 'default_value' => 'nullable|string', 'options' => 'nullable|array', ]); $result = $this->service->createCustomField($tenantId, $validated); return response()->json($result, $result['success'] ? 200 : 400); } /** * 커스텀 필드 수정 */ public function updateCustomField(Request $request, int $id): JsonResponse { $tenantId = session('selected_tenant_id'); if (! $tenantId || $tenantId === 'all') { return response()->json([ 'success' => false, 'message' => '테넌트를 선택해주세요.', ], 400); } $validated = $request->validate([ 'source_table' => 'nullable|string', 'field_key' => 'nullable|string|max:100|regex:/^[a-zA-Z0-9_]+$/', 'field_name' => 'required|string|max:255', 'field_type' => 'required|string|in:textbox,number,dropdown,checkbox,date,textarea', 'is_required' => 'nullable|boolean', 'default_value' => 'nullable|string', 'options' => 'nullable|array', ]); $result = $this->service->updateCustomField($tenantId, $id, $validated); return response()->json($result, $result['success'] ? 200 : 400); } /** * 커스텀 필드 삭제 */ public function destroyCustomField(int $id): JsonResponse { $tenantId = session('selected_tenant_id'); if (! $tenantId || $tenantId === 'all') { return response()->json([ 'success' => false, 'message' => '테넌트를 선택해주세요.', ], 400); } $result = $this->service->deleteCustomField($tenantId, $id); return response()->json($result, $result['success'] ? 200 : 400); } /** * 소프트 삭제된 커스텀 필드 복원 */ public function restoreCustomField(int $id): JsonResponse { $tenantId = session('selected_tenant_id'); if (! $tenantId || $tenantId === 'all') { return response()->json([ 'success' => false, 'message' => '테넌트를 선택해주세요.', ], 400); } $result = $this->service->restoreCustomField($tenantId, $id); return response()->json($result, $result['success'] ? 200 : 400); } /** * 커스텀 필드 영구 삭제 */ public function forceDestroyCustomField(int $id): JsonResponse { $tenantId = session('selected_tenant_id'); if (! $tenantId || $tenantId === 'all') { return response()->json([ 'success' => false, 'message' => '테넌트를 선택해주세요.', ], 400); } $result = $this->service->forceDeleteCustomField($tenantId, $id); return response()->json($result, $result['success'] ? 200 : 400); } /** * 커스텀 필드 일괄 삭제 */ public function destroyCustomFields(Request $request): JsonResponse { $tenantId = session('selected_tenant_id'); if (! $tenantId || $tenantId === 'all') { return response()->json([ 'success' => false, 'message' => '테넌트를 선택해주세요.', ], 400); } $validated = $request->validate([ 'ids' => 'required|array', 'ids.*' => 'required|integer', ]); $result = $this->service->deleteCustomFields($tenantId, $validated['ids']); return response()->json($result); } /** * 소스 테이블 목록 (JSON) */ public function sourceTables(): JsonResponse { return response()->json([ 'success' => true, 'data' => SystemFieldDefinition::getSourceTableOptions(), ]); } /** * 오류 로그 조회 (HTMX partial) */ public function errorLogs(): View { $errorLogs = $this->service->getErrorLogs(); return view('item-fields.partials.error-logs', compact('errorLogs')); } /** * 오류 로그 초기화 */ public function clearErrorLogs(): JsonResponse { $this->service->clearErrorLogs(); return response()->json([ 'success' => true, 'message' => '오류 로그가 초기화되었습니다.', ]); } /** * AI 문의용 오류 보고서 생성 */ public function generateErrorReport(): JsonResponse { $tenantId = session('selected_tenant_id'); $errorLogs = $this->service->getErrorLogs(); if (empty($errorLogs)) { return response()->json([ 'success' => false, 'message' => '저장된 오류 로그가 없습니다.', ]); } $latestError = $errorLogs[0]; // 보고서 생성 $report = $this->buildErrorReport($latestError, $tenantId); return response()->json([ 'success' => true, 'report' => $report, 'error_count' => count($latestError['errors'] ?? []), ]); } /** * 오류 보고서 텍스트 생성 */ protected function buildErrorReport(array $errorLog, ?int $tenantId): string { $errors = $errorLog['errors'] ?? []; $sourceTable = $errorLog['source_table'] ?? 'unknown'; $timestamp = $errorLog['timestamp'] ?? now()->toDateTimeString(); $report = "## 품목기준 필드 시딩 오류 보고서\n\n"; $report .= "### 기본 정보\n"; $report .= "- **발생 시각**: {$timestamp}\n"; $report .= "- **테넌트 ID**: {$tenantId}\n"; $report .= "- **소스 테이블**: {$sourceTable}\n"; $report .= '- **오류 수**: '.count($errors)."건\n\n"; $report .= "### 오류 상세\n"; foreach ($errors as $index => $error) { $num = $index + 1; $report .= "\n#### 오류 #{$num}\n"; $report .= "- **필드 키**: `{$error['field_key']}`\n"; $report .= "- **필드명**: {$error['field_name']}\n"; $report .= "- **오류 코드**: {$error['error_code']}\n"; $report .= "- **오류 메시지**:\n```\n{$error['error_message']}\n```\n"; } $report .= "\n### 환경 정보\n"; $report .= "- **애플리케이션**: MNG (품목기준 필드 관리)\n"; $report .= "- **DB 테이블**: item_fields\n"; $report .= "- **관련 파일**: `app/Services/ItemFieldSeedingService.php`\n"; $report .= "\n### 요청 사항\n"; $report .= "위 오류의 원인을 분석하고 해결 방법을 제시해 주세요.\n"; return $report; } // ========================================================================= // 시스템 필드 정의 관리 (system_field_definitions 테이블) // ========================================================================= /** * 시스템 필드 정의 목록 (HTMX partial) */ public function systemFieldDefinitions(Request $request): View { $query = SystemFieldDefinition::query() ->where('is_active', true) ->orderBy('source_table') ->orderBy('order_no'); // 소스 테이블 필터 if ($request->filled('source_table')) { $query->where('source_table', $request->input('source_table')); } // 검색 if ($request->filled('search')) { $search = $request->input('search'); $query->where(function ($q) use ($search) { $q->where('field_key', 'like', "%{$search}%") ->orWhere('field_name', 'like', "%{$search}%"); }); } $definitions = $query->get(); $sourceTables = SystemFieldDefinition::getSourceTableOptions(); return view('item-fields.partials.system-field-definitions', compact('definitions', 'sourceTables')); } /** * 시스템 필드 정의 추가 */ public function storeSystemFieldDefinition(Request $request): JsonResponse { $validated = $request->validate([ 'source_table' => 'required|string|max:50', 'source_table_label' => 'required|string|max:50', 'field_key' => 'required|string|max:100|regex:/^[a-zA-Z0-9_]+$/', 'field_name' => 'required|string|max:100', 'field_type' => 'required|string|in:textbox,number,dropdown,checkbox,date,textarea', 'order_no' => 'nullable|integer|min:0', 'is_required' => 'nullable|boolean', 'is_seed_default' => 'nullable|boolean', 'default_value' => 'nullable|string|max:255', 'options' => 'nullable|array', ]); // 중복 체크 $exists = SystemFieldDefinition::where('source_table', $validated['source_table']) ->where('field_key', $validated['field_key']) ->exists(); if ($exists) { return response()->json([ 'success' => false, 'message' => '이미 존재하는 필드 키입니다.', ], 400); } // order_no가 없으면 마지막 순서로 설정 if (! isset($validated['order_no'])) { $maxOrder = SystemFieldDefinition::where('source_table', $validated['source_table'])->max('order_no'); $validated['order_no'] = ($maxOrder ?? 0) + 1; } $validated['is_required'] = $validated['is_required'] ?? false; $validated['is_seed_default'] = $validated['is_seed_default'] ?? true; $validated['is_active'] = true; SystemFieldDefinition::create($validated); return response()->json([ 'success' => true, 'message' => '시스템 필드 정의가 추가되었습니다.', ]); } /** * 시스템 필드 정의 수정 */ public function updateSystemFieldDefinition(Request $request, int $id): JsonResponse { $definition = SystemFieldDefinition::find($id); if (! $definition) { return response()->json([ 'success' => false, 'message' => '시스템 필드 정의를 찾을 수 없습니다.', ], 404); } $validated = $request->validate([ 'source_table' => 'nullable|string|max:50', 'source_table_label' => 'nullable|string|max:50', 'field_key' => 'nullable|string|max:100|regex:/^[a-zA-Z0-9_]+$/', 'field_name' => 'required|string|max:100', 'field_type' => 'required|string|in:textbox,number,dropdown,checkbox,date,textarea', 'order_no' => 'nullable|integer|min:0', 'is_required' => 'nullable|boolean', 'is_seed_default' => 'nullable|boolean', 'default_value' => 'nullable|string|max:255', 'options' => 'nullable|array', ]); // source_table 또는 field_key 변경 시 중복 체크 $newSourceTable = $validated['source_table'] ?? $definition->source_table; $newFieldKey = $validated['field_key'] ?? $definition->field_key; if ($newSourceTable !== $definition->source_table || $newFieldKey !== $definition->field_key) { $exists = SystemFieldDefinition::where('source_table', $newSourceTable) ->where('field_key', $newFieldKey) ->where('id', '!=', $id) ->exists(); if ($exists) { return response()->json([ 'success' => false, 'message' => '이미 존재하는 필드 키입니다.', ], 400); } } $definition->update($validated); return response()->json([ 'success' => true, 'message' => '시스템 필드 정의가 수정되었습니다.', ]); } /** * 시스템 필드 정의 삭제 */ public function destroySystemFieldDefinition(int $id): JsonResponse { $definition = SystemFieldDefinition::find($id); if (! $definition) { return response()->json([ 'success' => false, 'message' => '시스템 필드 정의를 찾을 수 없습니다.', ], 404); } $definition->delete(); return response()->json([ 'success' => true, 'message' => '시스템 필드 정의가 삭제되었습니다.', ]); } /** * 시스템 필드 정의 순서 변경 */ public function reorderSystemFieldDefinitions(Request $request): JsonResponse { $validated = $request->validate([ 'items' => 'required|array', 'items.*.id' => 'required|integer', 'items.*.order_no' => 'required|integer|min:0', ]); foreach ($validated['items'] as $item) { SystemFieldDefinition::where('id', $item['id'])->update(['order_no' => $item['order_no']]); } return response()->json([ 'success' => true, 'message' => '순서가 변경되었습니다.', ]); } /** * 소스 테이블 추가 (DB 테이블의 컬럼을 자동으로 필드 정의로 생성) */ public function storeSourceTable(Request $request): JsonResponse { $validated = $request->validate([ 'source_table' => 'required|string|max:50', 'source_table_label' => 'required|string|max:50', ]); $tableName = $validated['source_table']; $tableLabel = $validated['source_table_label']; // 실제 DB에 테이블이 존재하는지 확인 if (! Schema::hasTable($tableName)) { return response()->json([ 'success' => false, 'message' => '데이터베이스에 존재하지 않는 테이블입니다.', ], 400); } // 이미 등록된 테이블인지 확인 $exists = SystemFieldDefinition::where('source_table', $tableName)->exists(); if ($exists) { return response()->json([ 'success' => false, 'message' => '이미 존재하는 테이블입니다.', ], 400); } // 시스템/메타 컬럼 제외 목록 $excludedColumns = [ 'id', 'tenant_id', 'created_at', 'updated_at', 'deleted_at', 'created_by', 'updated_by', 'deleted_by', ]; // MySQL INFORMATION_SCHEMA에서 컬럼 정보 조회 (COMMENT 포함) $dbName = config('database.connections.mysql.database'); $columnInfos = DB::select(" SELECT COLUMN_NAME, COLUMN_TYPE, COLUMN_COMMENT, IS_NULLABLE FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? ORDER BY ORDINAL_POSITION ", [$dbName, $tableName]); $createdCount = 0; $orderNo = 1; foreach ($columnInfos as $columnInfo) { $column = $columnInfo->COLUMN_NAME; // 시스템 컬럼 제외 if (in_array($column, $excludedColumns)) { continue; } $columnType = Schema::getColumnType($tableName, $column); $fieldType = $this->suggestFieldType($columnType); // COMMENT가 있으면 사용, 없으면 컬럼명 변환 $fieldName = ! empty($columnInfo->COLUMN_COMMENT) ? $columnInfo->COLUMN_COMMENT : ucwords(str_replace('_', ' ', $column)); // 필수 여부 (NOT NULL이면 필수) $isRequired = $columnInfo->IS_NULLABLE === 'NO'; SystemFieldDefinition::create([ 'source_table' => $tableName, 'source_table_label' => $tableLabel, 'field_key' => $column, 'field_name' => $fieldName, 'field_type' => $fieldType, 'order_no' => $orderNo++, 'is_required' => $isRequired, 'is_seed_default' => true, 'is_active' => true, ]); $createdCount++; } return response()->json([ 'success' => true, 'message' => "'{$tableLabel}' 테이블이 등록되었습니다. ({$createdCount}개 필드 생성)", 'field_count' => $createdCount, ]); } /** * 소스 테이블 삭제 (해당 테이블의 모든 필드 정의 삭제) */ public function destroySourceTable(string $sourceTable): JsonResponse { $count = SystemFieldDefinition::where('source_table', $sourceTable)->count(); if ($count === 0) { return response()->json([ 'success' => false, 'message' => '테이블을 찾을 수 없습니다.', ], 404); } SystemFieldDefinition::where('source_table', $sourceTable)->delete(); return response()->json([ 'success' => true, 'message' => "테이블과 {$count}개의 필드 정의가 삭제되었습니다.", ]); } /** * 소스 테이블 필드명 동기화 (DB COMMENT로 업데이트) */ public function syncSourceTableFieldNames(string $sourceTable): JsonResponse { // 등록된 테이블인지 확인 $fields = SystemFieldDefinition::where('source_table', $sourceTable)->get(); if ($fields->isEmpty()) { return response()->json([ 'success' => false, 'message' => '등록되지 않은 테이블입니다.', ], 404); } // 실제 DB에 테이블이 존재하는지 확인 if (! Schema::hasTable($sourceTable)) { return response()->json([ 'success' => false, 'message' => '데이터베이스에 존재하지 않는 테이블입니다.', ], 400); } // MySQL INFORMATION_SCHEMA에서 컬럼 정보 조회 (COMMENT 포함) $dbName = config('database.connections.mysql.database'); $columnInfos = DB::select(" SELECT COLUMN_NAME, COLUMN_COMMENT, IS_NULLABLE FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? ", [$dbName, $sourceTable]); // 컬럼명 => COMMENT 매핑 $columnComments = collect($columnInfos)->keyBy('COLUMN_NAME'); $updatedCount = 0; foreach ($fields as $field) { $columnInfo = $columnComments->get($field->field_key); if ($columnInfo && ! empty($columnInfo->COLUMN_COMMENT)) { // COMMENT가 있고, 현재 필드명과 다르면 업데이트 if ($field->field_name !== $columnInfo->COLUMN_COMMENT) { $field->update([ 'field_name' => $columnInfo->COLUMN_COMMENT, 'is_required' => $columnInfo->IS_NULLABLE === 'NO', ]); $updatedCount++; } } } return response()->json([ 'success' => true, 'message' => $updatedCount > 0 ? "{$updatedCount}개 필드명이 DB COMMENT로 업데이트되었습니다." : '업데이트할 필드가 없습니다. (이미 동기화됨)', 'updated_count' => $updatedCount, ]); } /** * 데이터베이스 테이블 목록 조회 (등록 가능한 테이블) */ public function databaseTables(Request $request): JsonResponse { // 이미 등록된 테이블 목록 $registeredTables = SystemFieldDefinition::pluck('source_table')->unique()->toArray(); // 시스템 테이블 제외 목록 $excludedTables = [ 'migrations', 'password_reset_tokens', 'sessions', 'cache', 'cache_locks', 'jobs', 'job_batches', 'failed_jobs', 'personal_access_tokens', 'system_field_definitions', 'item_fields', 'item_field_values', 'audit_logs', 'activity_log', 'telescope_entries', 'telescope_entries_tags', 'telescope_monitoring', 'pulse_aggregates', 'pulse_entries', 'pulse_values', ]; // DB 테이블 목록 조회 $tables = collect(DB::select('SHOW TABLES')) ->map(fn ($table) => array_values((array) $table)[0]) ->filter(function ($table) use ($excludedTables, $registeredTables) { // 시스템 테이블 제외 if (in_array($table, $excludedTables)) { return false; } // 피벗 테이블 제외 (2개의 테이블명이 _로 연결된 경우) if (preg_match('/^[a-z]+_[a-z]+_[a-z]+$/', $table) && ! str_contains($table, 'field')) { return false; } return true; }) ->map(function ($table) use ($registeredTables) { // 테이블 컬럼 정보 조회 $columns = Schema::getColumnListing($table); $columnDetails = []; foreach ($columns as $column) { $type = Schema::getColumnType($table, $column); $columnDetails[] = [ 'name' => $column, 'type' => $type, 'type_label' => $this->getColumnTypeLabel($type), ]; } return [ 'table' => $table, 'columns' => $columnDetails, 'column_count' => count($columns), 'is_registered' => in_array($table, $registeredTables), ]; }) ->values(); // 검색 필터 if ($request->filled('search')) { $search = strtolower($request->input('search')); $tables = $tables->filter(function ($t) use ($search) { return str_contains(strtolower($t['table']), $search); })->values(); } // 등록되지 않은 테이블만 필터 if ($request->boolean('unregistered_only', false)) { $tables = $tables->filter(fn ($t) => ! $t['is_registered'])->values(); } return response()->json([ 'success' => true, 'data' => $tables, 'total' => $tables->count(), ]); } /** * 특정 테이블의 컬럼 목록 조회 (COMMENT 포함) */ public function tableColumns(string $table): JsonResponse { if (! Schema::hasTable($table)) { return response()->json([ 'success' => false, 'message' => '테이블을 찾을 수 없습니다.', ], 404); } // MySQL INFORMATION_SCHEMA에서 컬럼 정보 조회 (COMMENT 포함) $dbName = config('database.connections.mysql.database'); $columnInfos = DB::select(" SELECT COLUMN_NAME, COLUMN_TYPE, DATA_TYPE, COLUMN_COMMENT, IS_NULLABLE FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? ORDER BY ORDINAL_POSITION ", [$dbName, $table]); $columnDetails = []; foreach ($columnInfos as $info) { $type = Schema::getColumnType($table, $info->COLUMN_NAME); $columnDetails[] = [ 'name' => $info->COLUMN_NAME, 'type' => $type, 'type_label' => $this->getColumnTypeLabel($type), 'comment' => $info->COLUMN_COMMENT ?: null, 'is_nullable' => $info->IS_NULLABLE === 'YES', 'suggested_field_type' => $this->suggestFieldType($type), ]; } return response()->json([ 'success' => true, 'table' => $table, 'columns' => $columnDetails, ]); } /** * 컬럼 타입 라벨 변환 */ private function getColumnTypeLabel(string $type): string { return match ($type) { 'integer', 'bigint', 'smallint', 'tinyint' => '정수', 'decimal', 'float', 'double' => '실수', 'string', 'varchar', 'char' => '문자열', 'text', 'longtext', 'mediumtext' => '텍스트', 'boolean' => '불린', 'date' => '날짜', 'datetime', 'timestamp' => '일시', 'json' => 'JSON', default => $type, }; } /** * 컬럼 타입에 따른 필드 타입 추천 */ private function suggestFieldType(string $type): string { return match ($type) { 'integer', 'bigint', 'smallint', 'tinyint', 'decimal', 'float', 'double' => 'number', 'boolean' => 'checkbox', 'date', 'datetime', 'timestamp' => 'date', 'text', 'longtext', 'mediumtext' => 'textarea', default => 'textbox', }; } }