Files
sam-manage/app/Services/Sales/InterviewScenarioService.php
김보곤 2a45b6bfe8 feat: [interview] 카테고리 계층 구조(대분류/중분류) 지원
- InterviewCategory 모델에 parent/children 관계 추가
- Service: getTree, getProjectTree 루트+children eager loading
- Service: createCategory에 parent_id 지원
- Service: cloneMaster 2단계 계층 복제
- Controller: storeCategory validation에 parent_id 추가
- UI: CategorySidebar/DomainSidebar 트리 뷰 렌더링
- UI: findCategory 헬퍼로 트리 내 카테고리 검색
2026-02-28 21:23:30 +09:00

733 lines
26 KiB
PHP

<?php
namespace App\Services\Sales;
use App\Models\Interview\InterviewAnswer;
use App\Models\Interview\InterviewAttachment;
use App\Models\Interview\InterviewCategory;
use App\Models\Interview\InterviewKnowledge;
use App\Models\Interview\InterviewProject;
use App\Models\Interview\InterviewQuestion;
use App\Models\Interview\InterviewSession;
use App\Models\Interview\InterviewTemplate;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
class InterviewScenarioService
{
// ============================================================
// 카테고리 CRUD
// ============================================================
public function getCategories()
{
return InterviewCategory::whereNull('parent_id')
->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();
}
}