with(['children' => fn ($q) => $q->orderBy('sort_order')->orderBy('id')]) ->orderBy('sort_order') ->orderBy('id') ->get(); } public function createCategory(array $data): InterviewCategory { $tenantId = session('selected_tenant_id', 1); $parentId = $data['parent_id'] ?? null; $query = InterviewCategory::query(); if ($parentId) { $query->where('parent_id', $parentId); } else { $query->whereNull('parent_id'); } $maxSort = $query->max('sort_order') ?? 0; return InterviewCategory::create([ 'tenant_id' => $tenantId, 'parent_id' => $parentId, 'name' => $data['name'], 'description' => $data['description'] ?? null, 'sort_order' => $maxSort + 1, 'is_active' => true, 'created_by' => auth()->id(), 'updated_by' => auth()->id(), ]); } public function updateCategory(int $id, array $data): InterviewCategory { $category = InterviewCategory::findOrFail($id); $category->update([ 'name' => $data['name'], 'description' => $data['description'] ?? null, 'updated_by' => auth()->id(), ]); return $category->fresh(); } public function deleteCategory(int $id): void { $category = InterviewCategory::findOrFail($id); $category->update(['deleted_by' => auth()->id()]); $category->delete(); } // ============================================================ // 템플릿(항목) CRUD // ============================================================ public function createTemplate(array $data): InterviewTemplate { $tenantId = session('selected_tenant_id', 1); $maxSort = InterviewTemplate::where('interview_category_id', $data['interview_category_id']) ->max('sort_order') ?? 0; return InterviewTemplate::create([ 'tenant_id' => $tenantId, 'interview_category_id' => $data['interview_category_id'], 'name' => $data['name'], 'description' => $data['description'] ?? null, 'sort_order' => $maxSort + 1, 'is_active' => true, 'created_by' => auth()->id(), 'updated_by' => auth()->id(), ]); } public function updateTemplate(int $id, array $data): InterviewTemplate { $template = InterviewTemplate::findOrFail($id); $template->update([ 'name' => $data['name'], 'description' => $data['description'] ?? null, 'updated_by' => auth()->id(), ]); return $template->fresh(); } public function deleteTemplate(int $id): void { $template = InterviewTemplate::findOrFail($id); $template->update(['deleted_by' => auth()->id()]); $template->delete(); } // ============================================================ // 질문 CRUD // ============================================================ public function createQuestion(array $data): InterviewQuestion { $tenantId = session('selected_tenant_id', 1); $maxSort = InterviewQuestion::where('interview_template_id', $data['interview_template_id']) ->max('sort_order') ?? 0; return InterviewQuestion::create([ 'tenant_id' => $tenantId, 'interview_template_id' => $data['interview_template_id'], 'question_text' => $data['question_text'], 'question_type' => $data['question_type'] ?? 'checkbox', 'options' => $data['options'] ?? null, 'ai_hint' => $data['ai_hint'] ?? null, 'expected_format' => $data['expected_format'] ?? null, 'depends_on' => $data['depends_on'] ?? null, 'domain' => $data['domain'] ?? null, 'is_required' => $data['is_required'] ?? false, 'sort_order' => $maxSort + 1, 'is_active' => true, 'created_by' => auth()->id(), 'updated_by' => auth()->id(), ]); } public function updateQuestion(int $id, array $data): InterviewQuestion { $question = InterviewQuestion::findOrFail($id); $question->update([ 'question_text' => $data['question_text'], 'question_type' => $data['question_type'] ?? $question->question_type, 'is_required' => $data['is_required'] ?? $question->is_required, 'updated_by' => auth()->id(), ]); return $question->fresh(); } public function deleteQuestion(int $id): void { $question = InterviewQuestion::findOrFail($id); $question->update(['deleted_by' => auth()->id()]); $question->delete(); } // ============================================================ // MD 파일 일괄 가져오기 // ============================================================ public function bulkImport(int $categoryId, array $templates): array { return DB::transaction(function () use ($categoryId, $templates) { $tenantId = session('selected_tenant_id', 1); $userId = auth()->id(); $maxTemplateSort = InterviewTemplate::where('interview_category_id', $categoryId) ->max('sort_order') ?? 0; $createdTemplates = 0; $createdQuestions = 0; foreach ($templates as $tpl) { $maxTemplateSort++; $template = InterviewTemplate::create([ 'tenant_id' => $tenantId, 'interview_category_id' => $categoryId, 'name' => $tpl['name'], 'sort_order' => $maxTemplateSort, 'is_active' => true, 'created_by' => $userId, 'updated_by' => $userId, ]); $createdTemplates++; $questionSort = 0; foreach ($tpl['questions'] as $questionText) { $questionSort++; InterviewQuestion::create([ 'tenant_id' => $tenantId, 'interview_template_id' => $template->id, 'question_text' => $questionText, 'question_type' => 'checkbox', 'is_required' => false, 'sort_order' => $questionSort, 'is_active' => true, 'created_by' => $userId, 'updated_by' => $userId, ]); $createdQuestions++; } } return [ 'templates_created' => $createdTemplates, 'questions_created' => $createdQuestions, ]; }); } // ============================================================ // 전체 트리 조회 // ============================================================ public function getTree() { return InterviewCategory::whereNull('parent_id') ->with([ 'children' => fn ($q) => $q->orderBy('sort_order')->orderBy('id') ->with(['templates' => fn ($q2) => $q2->orderBy('sort_order')->orderBy('id') ->with(['questions' => fn ($q3) => $q3->orderBy('sort_order')->orderBy('id')]), ]), 'templates' => fn ($q) => $q->orderBy('sort_order')->orderBy('id') ->with(['questions' => fn ($q2) => $q2->orderBy('sort_order')->orderBy('id')]), ]) ->orderBy('sort_order') ->orderBy('id') ->get(); } // ============================================================ // 세션 관리 // ============================================================ public function getSessions(array $filters = []) { $query = InterviewSession::with(['category', 'interviewer']) ->orderByDesc('interview_date') ->orderByDesc('id'); if (! empty($filters['status'])) { $query->where('status', $filters['status']); } if (! empty($filters['category_id'])) { $query->where('interview_category_id', $filters['category_id']); } return $query->paginate(20); } public function startSession(array $data): InterviewSession { return DB::transaction(function () use ($data) { $tenantId = session('selected_tenant_id', 1); $categoryId = $data['interview_category_id']; // 카테고리의 모든 활성 템플릿과 질문 가져오기 $templates = InterviewTemplate::where('interview_category_id', $categoryId) ->where('is_active', true) ->with(['questions' => function ($q) { $q->where('is_active', true)->orderBy('sort_order'); }]) ->orderBy('sort_order') ->get(); $totalQuestions = $templates->sum(fn ($t) => $t->questions->count()); // 세션 생성 $session = InterviewSession::create([ 'tenant_id' => $tenantId, 'interview_category_id' => $categoryId, 'interviewer_id' => auth()->id(), 'interviewee_name' => $data['interviewee_name'] ?? null, 'interviewee_company' => $data['interviewee_company'] ?? null, 'interview_date' => $data['interview_date'] ?? now()->toDateString(), 'status' => 'in_progress', 'total_questions' => $totalQuestions, 'answered_questions' => 0, 'memo' => $data['memo'] ?? null, 'created_by' => auth()->id(), 'updated_by' => auth()->id(), ]); // 모든 질문에 대해 빈 답변 레코드 생성 foreach ($templates as $template) { foreach ($template->questions as $question) { InterviewAnswer::create([ 'tenant_id' => $tenantId, 'interview_session_id' => $session->id, 'interview_question_id' => $question->id, 'interview_template_id' => $template->id, 'is_checked' => false, ]); } } return $session; }); } public function getSessionDetail(int $id) { return InterviewSession::with([ 'category', 'interviewer', 'answers' => function ($q) { $q->with(['question', 'template']); }, ])->findOrFail($id); } public function toggleAnswer(array $data): InterviewAnswer { $answer = InterviewAnswer::where('interview_session_id', $data['session_id']) ->where('interview_question_id', $data['question_id']) ->firstOrFail(); $answer->update([ 'is_checked' => ! $answer->is_checked, 'answer_text' => $data['answer_text'] ?? $answer->answer_text, 'memo' => $data['memo'] ?? $answer->memo, ]); // answered_questions 갱신 $session = InterviewSession::findOrFail($data['session_id']); $answeredCount = InterviewAnswer::where('interview_session_id', $session->id) ->where('is_checked', true) ->count(); $session->update([ 'answered_questions' => $answeredCount, 'updated_by' => auth()->id(), ]); return $answer->fresh(); } public function completeSession(int $id): InterviewSession { $session = InterviewSession::findOrFail($id); $answeredCount = InterviewAnswer::where('interview_session_id', $session->id) ->where('is_checked', true) ->count(); $session->update([ 'status' => 'completed', 'answered_questions' => $answeredCount, 'completed_at' => now(), 'updated_by' => auth()->id(), ]); return $session->fresh(); } // ============================================================ // 프로젝트 CRUD // ============================================================ public function getProjects(array $filters = []) { $query = InterviewProject::with('creator') ->orderByDesc('id'); if (! empty($filters['status'])) { $query->where('status', $filters['status']); } if (! empty($filters['search'])) { $search = $filters['search']; $query->where(function ($q) use ($search) { $q->where('company_name', 'like', "%{$search}%") ->orWhere('company_type', 'like', "%{$search}%"); }); } return $query->paginate(20); } public function getProject(int $id): InterviewProject { return InterviewProject::with([ 'categories.templates.questions', 'attachments', 'sessions', ])->findOrFail($id); } public function createProject(array $data): InterviewProject { $tenantId = session('selected_tenant_id', 1); return DB::transaction(function () use ($data, $tenantId) { $project = InterviewProject::create([ 'tenant_id' => $tenantId, 'company_name' => $data['company_name'], 'company_type' => $data['company_type'] ?? null, 'contact_person' => $data['contact_person'] ?? null, 'contact_info' => $data['contact_info'] ?? null, 'status' => 'draft', 'product_categories' => $data['product_categories'] ?? null, 'progress_percent' => 0, 'created_by' => auth()->id(), 'updated_by' => auth()->id(), ]); // 마스터 질문 데이터에서 8개 도메인 카테고리 복제 $this->cloneMasterQuestionsToProject($project); return $project->fresh(); }); } public function updateProject(int $id, array $data): InterviewProject { $project = InterviewProject::findOrFail($id); $project->update([ ...$data, 'updated_by' => auth()->id(), ]); return $project->fresh(); } public function deleteProject(int $id): void { $project = InterviewProject::findOrFail($id); $project->update(['deleted_by' => auth()->id()]); $project->delete(); } /** * 마스터 질문 데이터를 프로젝트에 복제 */ private function cloneMasterQuestionsToProject(InterviewProject $project): void { $tenantId = $project->tenant_id; $userId = auth()->id(); // 루트 마스터 카테고리 (parent_id=null, project=null) $masterRoots = InterviewCategory::withoutGlobalScopes() ->where('tenant_id', $tenantId) ->whereNull('interview_project_id') ->whereNull('parent_id') ->with(['children.templates.questions', 'templates.questions']) ->orderBy('sort_order') ->get(); foreach ($masterRoots as $masterRoot) { // 루트 카테고리 복제 $newRoot = InterviewCategory::create([ 'tenant_id' => $tenantId, 'interview_project_id' => $project->id, 'parent_id' => null, 'name' => $masterRoot->name, 'description' => $masterRoot->description, 'domain' => $masterRoot->domain, 'sort_order' => $masterRoot->sort_order, 'is_active' => true, 'created_by' => $userId, 'updated_by' => $userId, ]); // 루트의 직접 templates 복제 $this->cloneTemplates($masterRoot->templates, $newRoot->id, $tenantId, $userId); // 자식 카테고리 복제 foreach ($masterRoot->children as $masterChild) { $newChild = InterviewCategory::create([ 'tenant_id' => $tenantId, 'interview_project_id' => $project->id, 'parent_id' => $newRoot->id, 'name' => $masterChild->name, 'description' => $masterChild->description, 'domain' => $masterChild->domain, 'sort_order' => $masterChild->sort_order, 'is_active' => true, 'created_by' => $userId, 'updated_by' => $userId, ]); $this->cloneTemplates($masterChild->templates, $newChild->id, $tenantId, $userId); } } } private function cloneTemplates($templates, int $categoryId, int $tenantId, ?int $userId): void { foreach ($templates as $masterTpl) { $newTpl = InterviewTemplate::create([ 'tenant_id' => $tenantId, 'interview_category_id' => $categoryId, 'name' => $masterTpl->name, 'description' => $masterTpl->description, 'sort_order' => $masterTpl->sort_order, 'is_active' => true, 'created_by' => $userId, 'updated_by' => $userId, ]); foreach ($masterTpl->questions as $masterQ) { InterviewQuestion::create([ 'tenant_id' => $tenantId, 'interview_template_id' => $newTpl->id, 'question_text' => $masterQ->question_text, 'question_type' => $masterQ->question_type, 'options' => $masterQ->options, 'ai_hint' => $masterQ->ai_hint, 'expected_format' => $masterQ->expected_format, 'depends_on' => $masterQ->depends_on, 'domain' => $masterQ->domain, 'is_required' => $masterQ->is_required, 'sort_order' => $masterQ->sort_order, 'is_active' => true, 'created_by' => $userId, 'updated_by' => $userId, ]); } } } /** * 프로젝트별 트리 조회 */ public function getProjectTree(int $projectId) { return InterviewCategory::where('interview_project_id', $projectId) ->whereNull('parent_id') ->with([ 'children' => fn ($q) => $q->orderBy('sort_order')->orderBy('id') ->with(['templates' => fn ($q2) => $q2->orderBy('sort_order')->orderBy('id') ->with(['questions' => fn ($q3) => $q3->orderBy('sort_order')->orderBy('id')]), ]), 'templates' => fn ($q) => $q->orderBy('sort_order')->orderBy('id') ->with(['questions' => fn ($q2) => $q2->orderBy('sort_order')->orderBy('id')]), ]) ->orderBy('sort_order') ->orderBy('id') ->get(); } /** * 프로젝트 진행률 갱신 */ public function updateProjectProgress(int $projectId): InterviewProject { $project = InterviewProject::findOrFail($projectId); // 프로젝트 연결 카테고리의 모든 질문 수 $totalQuestions = InterviewQuestion::whereHas('template.category', function ($q) use ($projectId) { $q->where('interview_project_id', $projectId); })->where('is_active', true)->count(); if ($totalQuestions === 0) { $project->update(['progress_percent' => 0, 'updated_by' => auth()->id()]); return $project->fresh(); } // 프로젝트 세션의 답변 완료 수 $answeredQuestions = InterviewAnswer::whereHas('session', function ($q) use ($projectId) { $q->where('interview_project_id', $projectId); })->where('is_checked', true)->count(); $progress = min(100, (int) round(($answeredQuestions / $totalQuestions) * 100)); $project->update(['progress_percent' => $progress, 'updated_by' => auth()->id()]); return $project->fresh(); } // ============================================================ // 첨부파일 관리 // ============================================================ public function getAttachments(int $projectId) { return InterviewAttachment::where('interview_project_id', $projectId) ->with('creator') ->orderByDesc('id') ->get(); } public function uploadAttachment(int $projectId, array $data, $file): InterviewAttachment { $tenantId = session('selected_tenant_id', 1); $path = $file->store("interviews/{$projectId}", 'local'); return InterviewAttachment::create([ 'tenant_id' => $tenantId, 'interview_project_id' => $projectId, 'file_type' => $data['file_type'] ?? 'other', 'file_name' => $file->getClientOriginalName(), 'file_path' => $path, 'file_size' => $file->getSize(), 'mime_type' => $file->getMimeType(), 'ai_analysis_status' => 'pending', 'description' => $data['description'] ?? null, 'created_by' => auth()->id(), ]); } public function deleteAttachment(int $id): void { $attachment = InterviewAttachment::findOrFail($id); if (Storage::disk('local')->exists($attachment->file_path)) { Storage::disk('local')->delete($attachment->file_path); } $attachment->delete(); } // ============================================================ // 지식 관리 // ============================================================ public function getKnowledge(int $projectId, array $filters = []) { $query = InterviewKnowledge::where('interview_project_id', $projectId) ->with('creator') ->orderByDesc('id'); if (! empty($filters['domain'])) { $query->where('domain', $filters['domain']); } if (! empty($filters['is_verified'])) { $query->where('is_verified', $filters['is_verified'] === 'true'); } if (! empty($filters['min_confidence'])) { $query->where('confidence', '>=', (float) $filters['min_confidence']); } return $query->get(); } public function createKnowledge(int $projectId, array $data): InterviewKnowledge { $tenantId = session('selected_tenant_id', 1); return InterviewKnowledge::create([ 'tenant_id' => $tenantId, 'interview_project_id' => $projectId, 'domain' => $data['domain'], 'knowledge_type' => $data['knowledge_type'], 'title' => $data['title'], 'content' => $data['content'], 'source_type' => $data['source_type'] ?? 'manual', 'source_id' => $data['source_id'] ?? null, 'confidence' => $data['confidence'] ?? 1.00, 'is_verified' => $data['source_type'] === 'manual', 'verified_by' => $data['source_type'] === 'manual' ? auth()->id() : null, 'verified_at' => $data['source_type'] === 'manual' ? now() : null, 'created_by' => auth()->id(), ]); } public function updateKnowledge(int $id, array $data): InterviewKnowledge { $knowledge = InterviewKnowledge::findOrFail($id); $knowledge->update($data); return $knowledge->fresh(); } public function verifyKnowledge(int $id): InterviewKnowledge { $knowledge = InterviewKnowledge::findOrFail($id); $knowledge->update([ 'is_verified' => ! $knowledge->is_verified, 'verified_by' => $knowledge->is_verified ? null : auth()->id(), 'verified_at' => $knowledge->is_verified ? null : now(), ]); return $knowledge->fresh(); } public function deleteKnowledge(int $id): void { $knowledge = InterviewKnowledge::findOrFail($id); $knowledge->delete(); } // ============================================================ // 답변 저장 (구조화 답변 포함) // ============================================================ public function saveAnswer(array $data): InterviewAnswer { $answer = InterviewAnswer::where('interview_session_id', $data['session_id']) ->where('interview_question_id', $data['question_id']) ->firstOrFail(); $updateData = [ 'is_checked' => $data['is_checked'] ?? $answer->is_checked, ]; if (isset($data['answer_text'])) { $updateData['answer_text'] = $data['answer_text']; } if (isset($data['answer_data'])) { $updateData['answer_data'] = $data['answer_data']; } if (isset($data['attachments'])) { $updateData['attachments'] = $data['attachments']; } if (isset($data['memo'])) { $updateData['memo'] = $data['memo']; } $answer->update($updateData); // answered_questions 갱신 $session = InterviewSession::findOrFail($data['session_id']); $answeredCount = InterviewAnswer::where('interview_session_id', $session->id) ->where('is_checked', true) ->count(); $session->update([ 'answered_questions' => $answeredCount, 'updated_by' => auth()->id(), ]); return $answer->fresh(); } }