feat: [interview] 인터뷰 시나리오 고도화 Phase 1 구현
- InterviewProject/Attachment/Knowledge 모델 3개 신규 - 기존 모델 확장 (Question, Answer, Session, Category) - 서비스 확장: 프로젝트 CRUD, 첨부파일, 지식 관리 - 컨트롤러 확장: 프로젝트/첨부/지식 API 엔드포인트 - 라우트 20개 추가 (프로젝트, 첨부, 지식) - InterviewQuestionMasterSeeder: 8개 도메인 80개 질문 - UI 확장: 프로젝트 모드/기존 모드 전환 - 프로젝트 선택 바, 상태 바, 도메인 사이드바 - 탭 구조 (질문편집/인터뷰/첨부파일/추출지식) - 구조화 답변 입력 (테이블, 수식, 다중선택 등) - 첨부파일 업로드/관리 - 지식 수동 추가/검증/필터링
This commit is contained in:
@@ -122,7 +122,12 @@ public function storeQuestion(Request $request): JsonResponse
|
||||
$validated = $request->validate([
|
||||
'interview_template_id' => 'required|integer|exists:interview_templates,id',
|
||||
'question_text' => 'required|string|max:500',
|
||||
'question_type' => 'nullable|string|in:checkbox,text',
|
||||
'question_type' => 'nullable|string|in:checkbox,text,number,select,multi_select,file_upload,formula_input,table_input,bom_tree,price_table,dimension_diagram',
|
||||
'options' => 'nullable|array',
|
||||
'ai_hint' => 'nullable|string',
|
||||
'expected_format' => 'nullable|string|max:100',
|
||||
'depends_on' => 'nullable|array',
|
||||
'domain' => 'nullable|string|max:50',
|
||||
'is_required' => 'nullable|boolean',
|
||||
]);
|
||||
|
||||
@@ -135,7 +140,7 @@ public function updateQuestion(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'question_text' => 'required|string|max:500',
|
||||
'question_type' => 'nullable|string|in:checkbox,text',
|
||||
'question_type' => 'nullable|string|in:checkbox,text,number,select,multi_select,file_upload,formula_input,table_input,bom_tree,price_table,dimension_diagram',
|
||||
'is_required' => 'nullable|boolean',
|
||||
]);
|
||||
|
||||
@@ -224,4 +229,180 @@ public function completeSession(int $id): JsonResponse
|
||||
|
||||
return response()->json($session);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 프로젝트 API
|
||||
// ============================================================
|
||||
|
||||
public function projects(Request $request): JsonResponse
|
||||
{
|
||||
$filters = $request->only(['status', 'search']);
|
||||
|
||||
return response()->json($this->service->getProjects($filters));
|
||||
}
|
||||
|
||||
public function showProject(int $id): JsonResponse
|
||||
{
|
||||
return response()->json($this->service->getProject($id));
|
||||
}
|
||||
|
||||
public function storeProject(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'company_name' => 'required|string|max:200',
|
||||
'company_type' => 'nullable|string|max:100',
|
||||
'contact_person' => 'nullable|string|max:100',
|
||||
'contact_info' => 'nullable|string|max:200',
|
||||
'product_categories' => 'nullable|array',
|
||||
]);
|
||||
|
||||
$project = $this->service->createProject($validated);
|
||||
|
||||
return response()->json($project, 201);
|
||||
}
|
||||
|
||||
public function updateProject(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'company_name' => 'sometimes|string|max:200',
|
||||
'company_type' => 'nullable|string|max:100',
|
||||
'contact_person' => 'nullable|string|max:100',
|
||||
'contact_info' => 'nullable|string|max:200',
|
||||
'status' => 'nullable|string|in:draft,interviewing,analyzing,code_generated,deployed',
|
||||
'product_categories' => 'nullable|array',
|
||||
'summary' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$project = $this->service->updateProject($id, $validated);
|
||||
|
||||
return response()->json($project);
|
||||
}
|
||||
|
||||
public function destroyProject(int $id): JsonResponse
|
||||
{
|
||||
$this->service->deleteProject($id);
|
||||
|
||||
return response()->json(['message' => '삭제되었습니다.']);
|
||||
}
|
||||
|
||||
public function projectTree(int $id): JsonResponse
|
||||
{
|
||||
return response()->json($this->service->getProjectTree($id));
|
||||
}
|
||||
|
||||
public function projectProgress(int $id): JsonResponse
|
||||
{
|
||||
$project = $this->service->updateProjectProgress($id);
|
||||
|
||||
return response()->json($project);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 첨부파일 API
|
||||
// ============================================================
|
||||
|
||||
public function attachments(int $projectId): JsonResponse
|
||||
{
|
||||
return response()->json($this->service->getAttachments($projectId));
|
||||
}
|
||||
|
||||
public function uploadAttachment(Request $request, int $projectId): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'file' => 'required|file|max:51200',
|
||||
'file_type' => 'nullable|string|in:excel_template,pdf_quote,sample_bom,price_list,photo,voice,other',
|
||||
'description' => 'nullable|string|max:500',
|
||||
]);
|
||||
|
||||
$attachment = $this->service->uploadAttachment(
|
||||
$projectId,
|
||||
$request->only(['file_type', 'description']),
|
||||
$request->file('file')
|
||||
);
|
||||
|
||||
return response()->json($attachment, 201);
|
||||
}
|
||||
|
||||
public function destroyAttachment(int $id): JsonResponse
|
||||
{
|
||||
$this->service->deleteAttachment($id);
|
||||
|
||||
return response()->json(['message' => '삭제되었습니다.']);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 지식 API
|
||||
// ============================================================
|
||||
|
||||
public function knowledge(Request $request, int $projectId): JsonResponse
|
||||
{
|
||||
$filters = $request->only(['domain', 'is_verified', 'min_confidence']);
|
||||
|
||||
return response()->json($this->service->getKnowledge($projectId, $filters));
|
||||
}
|
||||
|
||||
public function storeKnowledge(Request $request, int $projectId): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'domain' => 'required|string|in:product_classification,bom_structure,dimension_formula,component_config,pricing_structure,quantity_formula,conditional_logic,quote_format',
|
||||
'knowledge_type' => 'required|string|in:fact,rule,formula,mapping,range,table',
|
||||
'title' => 'required|string|max:300',
|
||||
'content' => 'required|array',
|
||||
'source_type' => 'nullable|string|in:interview_answer,voice_recording,document,manual',
|
||||
'source_id' => 'nullable|integer',
|
||||
'confidence' => 'nullable|numeric|min:0|max:1',
|
||||
]);
|
||||
|
||||
$knowledge = $this->service->createKnowledge($projectId, $validated);
|
||||
|
||||
return response()->json($knowledge, 201);
|
||||
}
|
||||
|
||||
public function updateKnowledge(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'title' => 'sometimes|string|max:300',
|
||||
'content' => 'sometimes|array',
|
||||
'confidence' => 'nullable|numeric|min:0|max:1',
|
||||
]);
|
||||
|
||||
$knowledge = $this->service->updateKnowledge($id, $validated);
|
||||
|
||||
return response()->json($knowledge);
|
||||
}
|
||||
|
||||
public function verifyKnowledge(int $id): JsonResponse
|
||||
{
|
||||
$knowledge = $this->service->verifyKnowledge($id);
|
||||
|
||||
return response()->json($knowledge);
|
||||
}
|
||||
|
||||
public function destroyKnowledge(int $id): JsonResponse
|
||||
{
|
||||
$this->service->deleteKnowledge($id);
|
||||
|
||||
return response()->json(['message' => '삭제되었습니다.']);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 구조화 답변 저장 API
|
||||
// ============================================================
|
||||
|
||||
public function saveAnswer(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'session_id' => 'required|integer',
|
||||
'question_id' => 'required|integer',
|
||||
'is_checked' => 'nullable|boolean',
|
||||
'answer_text' => 'nullable|string',
|
||||
'answer_data' => 'nullable|array',
|
||||
'attachments' => 'nullable|array',
|
||||
'memo' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$answer = $this->service->saveAnswer($validated);
|
||||
|
||||
return response()->json($answer);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,11 +16,15 @@ class InterviewAnswer extends Model
|
||||
'interview_template_id',
|
||||
'is_checked',
|
||||
'answer_text',
|
||||
'answer_data',
|
||||
'attachments',
|
||||
'memo',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_checked' => 'boolean',
|
||||
'answer_data' => 'array',
|
||||
'attachments' => 'array',
|
||||
];
|
||||
|
||||
public function session()
|
||||
|
||||
41
app/Models/Interview/InterviewAttachment.php
Normal file
41
app/Models/Interview/InterviewAttachment.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Interview;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Traits\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class InterviewAttachment extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'interview_project_id',
|
||||
'file_type',
|
||||
'file_name',
|
||||
'file_path',
|
||||
'file_size',
|
||||
'mime_type',
|
||||
'ai_analysis',
|
||||
'ai_analysis_status',
|
||||
'description',
|
||||
'created_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'ai_analysis' => 'array',
|
||||
'file_size' => 'integer',
|
||||
];
|
||||
|
||||
public function project()
|
||||
{
|
||||
return $this->belongsTo(InterviewProject::class, 'interview_project_id');
|
||||
}
|
||||
|
||||
public function creator()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
}
|
||||
@@ -13,8 +13,10 @@ class InterviewCategory extends Model
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'interview_project_id',
|
||||
'name',
|
||||
'description',
|
||||
'domain',
|
||||
'sort_order',
|
||||
'is_active',
|
||||
'created_by',
|
||||
@@ -27,6 +29,11 @@ class InterviewCategory extends Model
|
||||
'sort_order' => 'integer',
|
||||
];
|
||||
|
||||
public function project()
|
||||
{
|
||||
return $this->belongsTo(InterviewProject::class, 'interview_project_id');
|
||||
}
|
||||
|
||||
public function templates()
|
||||
{
|
||||
return $this->hasMany(InterviewTemplate::class, 'interview_category_id');
|
||||
|
||||
54
app/Models/Interview/InterviewKnowledge.php
Normal file
54
app/Models/Interview/InterviewKnowledge.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Interview;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Traits\BelongsToTenant;
|
||||
use App\Traits\ModelTrait;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class InterviewKnowledge extends Model
|
||||
{
|
||||
use BelongsToTenant, ModelTrait, SoftDeletes;
|
||||
|
||||
protected $table = 'interview_knowledge';
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'interview_project_id',
|
||||
'domain',
|
||||
'knowledge_type',
|
||||
'title',
|
||||
'content',
|
||||
'source_type',
|
||||
'source_id',
|
||||
'confidence',
|
||||
'is_verified',
|
||||
'verified_by',
|
||||
'verified_at',
|
||||
'created_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'content' => 'array',
|
||||
'confidence' => 'decimal:2',
|
||||
'is_verified' => 'boolean',
|
||||
'verified_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function project()
|
||||
{
|
||||
return $this->belongsTo(InterviewProject::class, 'interview_project_id');
|
||||
}
|
||||
|
||||
public function verifier()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'verified_by');
|
||||
}
|
||||
|
||||
public function creator()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
}
|
||||
60
app/Models/Interview/InterviewProject.php
Normal file
60
app/Models/Interview/InterviewProject.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Interview;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Traits\BelongsToTenant;
|
||||
use App\Traits\ModelTrait;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class InterviewProject extends Model
|
||||
{
|
||||
use BelongsToTenant, ModelTrait, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'company_name',
|
||||
'company_type',
|
||||
'contact_person',
|
||||
'contact_info',
|
||||
'status',
|
||||
'target_tenant_id',
|
||||
'product_categories',
|
||||
'summary',
|
||||
'progress_percent',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
'deleted_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'product_categories' => 'array',
|
||||
'progress_percent' => 'integer',
|
||||
];
|
||||
|
||||
public function categories()
|
||||
{
|
||||
return $this->hasMany(InterviewCategory::class, 'interview_project_id');
|
||||
}
|
||||
|
||||
public function attachments()
|
||||
{
|
||||
return $this->hasMany(InterviewAttachment::class, 'interview_project_id');
|
||||
}
|
||||
|
||||
public function knowledge()
|
||||
{
|
||||
return $this->hasMany(InterviewKnowledge::class, 'interview_project_id');
|
||||
}
|
||||
|
||||
public function sessions()
|
||||
{
|
||||
return $this->hasMany(InterviewSession::class, 'interview_project_id');
|
||||
}
|
||||
|
||||
public function creator()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,10 @@ class InterviewQuestion extends Model
|
||||
'question_text',
|
||||
'question_type',
|
||||
'options',
|
||||
'ai_hint',
|
||||
'expected_format',
|
||||
'depends_on',
|
||||
'domain',
|
||||
'is_required',
|
||||
'sort_order',
|
||||
'is_active',
|
||||
@@ -27,6 +31,7 @@ class InterviewQuestion extends Model
|
||||
|
||||
protected $casts = [
|
||||
'options' => 'array',
|
||||
'depends_on' => 'array',
|
||||
'is_required' => 'boolean',
|
||||
'is_active' => 'boolean',
|
||||
'sort_order' => 'integer',
|
||||
|
||||
@@ -13,12 +13,15 @@ class InterviewSession extends Model
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'interview_project_id',
|
||||
'interview_category_id',
|
||||
'interviewer_id',
|
||||
'interviewee_name',
|
||||
'interviewee_company',
|
||||
'interview_date',
|
||||
'status',
|
||||
'session_type',
|
||||
'voice_recording_id',
|
||||
'total_questions',
|
||||
'answered_questions',
|
||||
'memo',
|
||||
@@ -35,6 +38,11 @@ class InterviewSession extends Model
|
||||
'answered_questions' => 'integer',
|
||||
];
|
||||
|
||||
public function project()
|
||||
{
|
||||
return $this->belongsTo(InterviewProject::class, 'interview_project_id');
|
||||
}
|
||||
|
||||
public function category()
|
||||
{
|
||||
return $this->belongsTo(InterviewCategory::class, 'interview_category_id');
|
||||
|
||||
@@ -3,11 +3,15 @@
|
||||
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
|
||||
{
|
||||
@@ -113,6 +117,11 @@ public function createQuestion(array $data): InterviewQuestion
|
||||
'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,
|
||||
@@ -337,4 +346,348 @@ public function completeSession(int $id): InterviewSession
|
||||
|
||||
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();
|
||||
|
||||
// interview_project_id = NULL이고 domain이 있는 마스터 카테고리
|
||||
$masterCategories = InterviewCategory::withoutGlobalScopes()
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereNull('interview_project_id')
|
||||
->whereNotNull('domain')
|
||||
->with(['templates.questions'])
|
||||
->orderBy('sort_order')
|
||||
->get();
|
||||
|
||||
foreach ($masterCategories as $masterCat) {
|
||||
$newCat = InterviewCategory::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'interview_project_id' => $project->id,
|
||||
'name' => $masterCat->name,
|
||||
'description' => $masterCat->description,
|
||||
'domain' => $masterCat->domain,
|
||||
'sort_order' => $masterCat->sort_order,
|
||||
'is_active' => true,
|
||||
'created_by' => $userId,
|
||||
'updated_by' => $userId,
|
||||
]);
|
||||
|
||||
foreach ($masterCat->templates as $masterTpl) {
|
||||
$newTpl = InterviewTemplate::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'interview_category_id' => $newCat->id,
|
||||
'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)
|
||||
->with([
|
||||
'templates' => function ($q) {
|
||||
$q->orderBy('sort_order')->orderBy('id');
|
||||
$q->with(['questions' => function ($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();
|
||||
}
|
||||
}
|
||||
|
||||
326
database/seeders/InterviewQuestionMasterSeeder.php
Normal file
326
database/seeders/InterviewQuestionMasterSeeder.php
Normal file
@@ -0,0 +1,326 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\Interview\InterviewCategory;
|
||||
use App\Models\Interview\InterviewQuestion;
|
||||
use App\Models\Interview\InterviewTemplate;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 인터뷰 질문 마스터 데이터 시더
|
||||
*
|
||||
* 8개 도메인 × 세부 질문 (총 80개)
|
||||
* 이 시더는 프로젝트 생성 시 질문 템플릿으로 활용된다.
|
||||
*
|
||||
* 사용법:
|
||||
* docker exec sam-mng-1 php artisan db:seed --class=InterviewQuestionMasterSeeder
|
||||
*
|
||||
* 주의: 기존 인터뷰 질문/카테고리와 충돌하지 않도록
|
||||
* interview_project_id = NULL인 마스터 데이터로 생성한다.
|
||||
*/
|
||||
class InterviewQuestionMasterSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
DB::transaction(function () {
|
||||
$tenantId = 1;
|
||||
$userId = 1;
|
||||
|
||||
$domains = $this->getDomainData();
|
||||
|
||||
foreach ($domains as $domainKey => $domain) {
|
||||
$categorySortOrder = InterviewCategory::max('sort_order') ?? 0;
|
||||
|
||||
$category = InterviewCategory::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'interview_project_id' => null,
|
||||
'name' => $domain['name'],
|
||||
'description' => $domain['description'],
|
||||
'domain' => $domainKey,
|
||||
'sort_order' => $categorySortOrder + 1,
|
||||
'is_active' => true,
|
||||
'created_by' => $userId,
|
||||
'updated_by' => $userId,
|
||||
]);
|
||||
|
||||
foreach ($domain['templates'] as $tplIndex => $tpl) {
|
||||
$template = InterviewTemplate::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'interview_category_id' => $category->id,
|
||||
'name' => $tpl['name'],
|
||||
'sort_order' => $tplIndex + 1,
|
||||
'is_active' => true,
|
||||
'created_by' => $userId,
|
||||
'updated_by' => $userId,
|
||||
]);
|
||||
|
||||
foreach ($tpl['questions'] as $qIndex => $q) {
|
||||
InterviewQuestion::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'interview_template_id' => $template->id,
|
||||
'question_text' => $q['text'],
|
||||
'question_type' => $q['type'],
|
||||
'options' => $q['options'] ?? null,
|
||||
'ai_hint' => $q['ai_hint'] ?? null,
|
||||
'expected_format' => $q['expected_format'] ?? null,
|
||||
'depends_on' => $q['depends_on'] ?? null,
|
||||
'domain' => $domainKey,
|
||||
'is_required' => $q['is_required'] ?? false,
|
||||
'sort_order' => $qIndex + 1,
|
||||
'is_active' => true,
|
||||
'created_by' => $userId,
|
||||
'updated_by' => $userId,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function getDomainData(): array
|
||||
{
|
||||
return [
|
||||
// ================================================================
|
||||
// Domain 1: 제품 분류 체계
|
||||
// ================================================================
|
||||
'product_classification' => [
|
||||
'name' => '제품 분류 체계',
|
||||
'description' => '제품 카테고리, 모델 코드, 분류 기준 파악',
|
||||
'templates' => [
|
||||
[
|
||||
'name' => '제품 카테고리 구조',
|
||||
'questions' => [
|
||||
['text' => '귀사의 주요 제품군을 모두 나열해주세요', 'type' => 'text', 'ai_hint' => '쉼표 구분으로 제품군 나열', 'is_required' => true],
|
||||
['text' => '각 제품군의 하위 모델명과 코드 체계를 알려주세요', 'type' => 'table_input', 'options' => ['columns' => ['모델코드', '모델명', '비고']], 'ai_hint' => '코드-이름 매핑 테이블'],
|
||||
['text' => '제품을 분류하는 기준은 무엇인가요? (소재, 용도, 크기 등)', 'type' => 'multi_select', 'options' => ['choices' => ['소재별', '용도별', '크기별', '설치방식별', '인증여부별']]],
|
||||
['text' => '인증(인정) 제품과 비인증 제품의 구분이 있나요?', 'type' => 'select', 'options' => ['choices' => ['있음', '없음']]],
|
||||
['text' => '인증 제품의 경우 구성이 고정되나요?', 'type' => 'checkbox', 'depends_on' => ['question_index' => 3, 'value' => '있음']],
|
||||
['text' => '카테고리별 제품 수는 대략 몇 개인가요?', 'type' => 'number', 'expected_format' => '개'],
|
||||
['text' => '제품 코드 명명 규칙을 설명해주세요 (예: KSS01의 의미)', 'type' => 'text', 'ai_hint' => '코드 체계의 각 자릿수 의미'],
|
||||
['text' => '기존 시스템(ERP/엑셀)에서 사용하는 제품 분류 방식을 캡처하여 업로드해주세요', 'type' => 'file_upload'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'name' => '설치 유형별 분류',
|
||||
'questions' => [
|
||||
['text' => '설치 유형(벽면형, 측면형, 혼합형 등)에 따라 견적이 달라지나요?', 'type' => 'select', 'options' => ['choices' => ['예, 크게 달라짐', '약간 달라짐', '달라지지 않음']]],
|
||||
['text' => '각 설치 유형별로 어떤 부품이 달라지나요?', 'type' => 'table_input', 'options' => ['columns' => ['설치유형', '추가부품', '제외부품', '비고']]],
|
||||
['text' => '설치 유형에 따른 추가 비용 항목이 있나요?', 'type' => 'text'],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
// ================================================================
|
||||
// Domain 2: BOM 구조
|
||||
// ================================================================
|
||||
'bom_structure' => [
|
||||
'name' => 'BOM 구조',
|
||||
'description' => '완제품-부품 관계, 부품 카테고리, BOM 레벨',
|
||||
'templates' => [
|
||||
[
|
||||
'name' => '완제품-부품 관계',
|
||||
'questions' => [
|
||||
['text' => '대표 제품 1개의 완제품→부품 구성을 트리로 그려주세요', 'type' => 'bom_tree', 'ai_hint' => '최상위 제품부터 하위 부품까지 트리 구조', 'is_required' => true],
|
||||
['text' => '모든 제품에 공통으로 들어가는 부품은 무엇인가요?', 'type' => 'multi_select', 'options' => ['choices' => ['가이드레일', '케이스', '모터', '제어기', '브라켓', '볼트/너트']], 'ai_hint' => '직접 입력 가능'],
|
||||
['text' => '제품별로 선택적(옵션)인 부품은 무엇인가요?', 'type' => 'table_input', 'options' => ['columns' => ['제품명', '옵션부품', '적용조건']]],
|
||||
['text' => 'BOM이 현재 엑셀로 관리되고 있나요? 파일을 업로드해주세요', 'type' => 'file_upload'],
|
||||
['text' => '하위 부품의 단계(레벨)는 최대 몇 단계인가요?', 'type' => 'number', 'expected_format' => '단계'],
|
||||
['text' => '부품 수량이 고정인 것과 계산이 필요한 것을 구분해주세요', 'type' => 'table_input', 'options' => ['columns' => ['부품명', '고정/계산', '고정수량 또는 계산식']]],
|
||||
],
|
||||
],
|
||||
[
|
||||
'name' => '부품 카테고리',
|
||||
'questions' => [
|
||||
['text' => '부품을 카테고리로 분류하면 어떻게 나눠지나요? (본체, 절곡품, 전동부, 부자재 등)', 'type' => 'text', 'ai_hint' => '부품 분류 체계'],
|
||||
['text' => '각 카테고리에 속하는 부품 목록을 정리해주세요', 'type' => 'table_input', 'options' => ['columns' => ['카테고리', '부품명', '규격']]],
|
||||
['text' => '외주 구매 부품과 자체 제작 부품의 구분이 있나요?', 'type' => 'select', 'options' => ['choices' => ['있음', '없음']]],
|
||||
['text' => '부자재(볼트, 너트, 패킹 등)는 별도 관리하나요?', 'type' => 'checkbox'],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
// ================================================================
|
||||
// Domain 3: 치수/변수 계산
|
||||
// ================================================================
|
||||
'dimension_formula' => [
|
||||
'name' => '치수/변수 계산',
|
||||
'description' => '오픈 사이즈→제작 사이즈 변환, 파생 변수 계산',
|
||||
'templates' => [
|
||||
[
|
||||
'name' => '오픈 사이즈 → 제작 사이즈',
|
||||
'questions' => [
|
||||
['text' => '고객이 입력하는 기본 치수 항목은 무엇인가요? (폭, 높이, 깊이 등)', 'type' => 'multi_select', 'options' => ['choices' => ['폭(W)', '높이(H)', '깊이(D)', '두께(T)', '지름(Ø)']], 'is_required' => true],
|
||||
['text' => '오픈 사이즈에서 제작 사이즈로 변환할 때 더하는 마진값은?', 'type' => 'formula_input', 'ai_hint' => '예: W1 = W0 + 120, H1 = H0 + 50', 'expected_format' => 'mm'],
|
||||
['text' => '제품 카테고리별로 마진값이 다른가요?', 'type' => 'table_input', 'options' => ['columns' => ['제품카테고리', 'W 마진(mm)', 'H 마진(mm)', '비고']]],
|
||||
['text' => '면적(㎡) 계산 공식을 알려주세요', 'type' => 'formula_input', 'ai_hint' => '예: area = W1 * H1 / 1000000', 'expected_format' => '㎡'],
|
||||
['text' => '중량(kg) 계산 공식을 알려주세요', 'type' => 'formula_input', 'ai_hint' => '예: weight = area * 단위중량(kg/㎡)', 'expected_format' => 'kg'],
|
||||
['text' => '기타 파생 변수가 있나요? (예: 분할 개수, 절곡 길이 등)', 'type' => 'table_input', 'options' => ['columns' => ['변수명', '계산식', '단위', '비고']]],
|
||||
['text' => '치수 계산에 사용하는 엑셀 수식을 캡처해주세요', 'type' => 'file_upload'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'name' => '변수 의존 관계',
|
||||
'questions' => [
|
||||
['text' => '변수 간 의존 관계를 설명해주세요 (A는 B와 C로 계산)', 'type' => 'text', 'ai_hint' => '계산 순서와 변수 의존성'],
|
||||
['text' => '계산 순서가 중요한 변수가 있나요?', 'type' => 'text'],
|
||||
['text' => '단위는 mm, m, kg 중 어떤 것을 기본으로 사용하나요?', 'type' => 'select', 'options' => ['choices' => ['mm', 'm', 'cm', '혼용']]],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
// ================================================================
|
||||
// Domain 4: 부품 구성 상세
|
||||
// ================================================================
|
||||
'component_config' => [
|
||||
'name' => '부품 구성 상세',
|
||||
'description' => '주요 부품별 규격, 선택 기준, 특수 구성',
|
||||
'templates' => [
|
||||
[
|
||||
'name' => '주요 부품별 상세',
|
||||
'questions' => [
|
||||
['text' => '가이드레일의 표준 길이 규격은? (예: 1219, 2438, 3305mm)', 'type' => 'table_input', 'options' => ['columns' => ['규격코드', '길이(mm)', '비고']]],
|
||||
['text' => '가이드레일 길이 조합 규칙은? (어떤 길이를 몇 개 사용?)', 'type' => 'text', 'ai_hint' => '높이에 따른 가이드레일 조합 로직'],
|
||||
['text' => '케이스(하우징) 크기별 규격과 부속품 차이를 설명해주세요', 'type' => 'table_input', 'options' => ['columns' => ['케이스규격', '적용조건', '부속품']]],
|
||||
['text' => '모터 용량 종류와 선택 기준은? (무게별? 면적별?)', 'type' => 'table_input', 'options' => ['columns' => ['모터용량', '적용범위(최소)', '적용범위(최대)', '단위']], 'ai_hint' => '무게/면적 범위별 모터 매핑'],
|
||||
['text' => '모터 전압 옵션은? (380V, 220V 등)', 'type' => 'multi_select', 'options' => ['choices' => ['380V', '220V', '110V', 'DC 24V']]],
|
||||
['text' => '제어기 종류와 선택 기준은? (노출형/매립형 등)', 'type' => 'table_input', 'options' => ['columns' => ['제어기유형', '적용조건', '비고']]],
|
||||
['text' => '절곡품(판재 가공) 목록과 각각의 치수 결정 방식은?', 'type' => 'table_input', 'options' => ['columns' => ['절곡품명', '치수결정방식', '재질', '두께(mm)']]],
|
||||
['text' => '부자재(볼트, 너트, 패킹 등) 목록과 수량 결정 방식은?', 'type' => 'table_input', 'options' => ['columns' => ['부자재명', '규격', '수량결정방식', '기본수량']]],
|
||||
],
|
||||
],
|
||||
[
|
||||
'name' => '특수 구성',
|
||||
'questions' => [
|
||||
['text' => '연기차단재 등 특수 부품이 있나요? 적용 조건은?', 'type' => 'text'],
|
||||
['text' => '보강재(샤프트, 파이프, 앵글 등) 사용 조건은?', 'type' => 'table_input', 'options' => ['columns' => ['보강재명', '규격', '적용조건', '수량']]],
|
||||
['text' => '고객 요청에 따라 추가/제외되는 옵션 부품은?', 'type' => 'table_input', 'options' => ['columns' => ['옵션부품', '추가/제외', '추가비용', '비고']]],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
// ================================================================
|
||||
// Domain 5: 단가 체계
|
||||
// ================================================================
|
||||
'pricing_structure' => [
|
||||
'name' => '단가 체계',
|
||||
'description' => '단가 관리 방식, 계산 방식, 마진/LOSS율',
|
||||
'templates' => [
|
||||
[
|
||||
'name' => '단가 관리 방식',
|
||||
'questions' => [
|
||||
['text' => '부품별 단가를 어디서 관리하나요? (엑셀, ERP, 구두 등)', 'type' => 'select', 'options' => ['choices' => ['엑셀', 'ERP 시스템', '구두/경험', '기타']]],
|
||||
['text' => '단가표 파일을 업로드해주세요', 'type' => 'file_upload'],
|
||||
['text' => '단가 변경 주기는? (월/분기/연 등)', 'type' => 'select', 'options' => ['choices' => ['수시', '월 단위', '분기 단위', '반기 단위', '연 단위']]],
|
||||
['text' => '단가에 포함되는 항목은? (재료비만? 가공비 포함?)', 'type' => 'multi_select', 'options' => ['choices' => ['재료비', '가공비', '운송비', '설치비', '마진']]],
|
||||
['text' => '고객별/거래처별 차등 단가가 있나요?', 'type' => 'select', 'options' => ['choices' => ['있음 (등급별)', '있음 (거래처별)', '없음 (일괄 동일)']]],
|
||||
['text' => 'LOSS율(손실률)을 적용하나요? 적용 방식은?', 'type' => 'formula_input', 'ai_hint' => '예: 실제수량 = 계산수량 × (1 + LOSS율)', 'expected_format' => '%'],
|
||||
['text' => '마진율 설정 방식은? (일괄? 품목별?)', 'type' => 'select', 'options' => ['choices' => ['일괄 마진율', '품목별 마진율', '카테고리별 마진율', '고객별 마진율']]],
|
||||
],
|
||||
],
|
||||
[
|
||||
'name' => '단가 계산 방식',
|
||||
'questions' => [
|
||||
['text' => '면적 기반 단가 품목은? (원/㎡)', 'type' => 'table_input', 'options' => ['columns' => ['품목명', '단가(원/㎡)', '비고']], 'expected_format' => '원/㎡'],
|
||||
['text' => '중량 기반 단가 품목은? (원/kg)', 'type' => 'table_input', 'options' => ['columns' => ['품목명', '단가(원/kg)', '비고']], 'expected_format' => '원/kg'],
|
||||
['text' => '수량 기반 단가 품목은? (원/EA)', 'type' => 'table_input', 'options' => ['columns' => ['품목명', '단가(원/EA)', '비고']], 'expected_format' => '원/EA'],
|
||||
['text' => '길이 기반 단가 품목은? (원/m)', 'type' => 'table_input', 'options' => ['columns' => ['품목명', '단가(원/m)', '비고']], 'expected_format' => '원/m'],
|
||||
['text' => '기타 특수 단가 계산 방식이 있나요?', 'type' => 'text'],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
// ================================================================
|
||||
// Domain 6: 수량 수식
|
||||
// ================================================================
|
||||
'quantity_formula' => [
|
||||
'name' => '수량 수식',
|
||||
'description' => '부품별 수량 결정 규칙, 계산식, 검증',
|
||||
'templates' => [
|
||||
[
|
||||
'name' => '수량 결정 규칙',
|
||||
'questions' => [
|
||||
['text' => '고정 수량 부품 목록 (항상 1개, 2개 등)', 'type' => 'table_input', 'options' => ['columns' => ['부품명', '고정수량', '비고']]],
|
||||
['text' => '치수 기반 수량 계산 부품과 수식', 'type' => 'formula_input', 'ai_hint' => '예: 슬랫수량 = CEIL(H1 / 슬랫피치)'],
|
||||
['text' => '면적 기반 수량 계산 부품과 수식', 'type' => 'formula_input', 'ai_hint' => '예: 스크린수량 = area / 기준면적'],
|
||||
['text' => '중량 기반 수량 계산 부품과 수식', 'type' => 'formula_input'],
|
||||
['text' => '올림/내림/반올림 규칙이 있는 계산은?', 'type' => 'table_input', 'options' => ['columns' => ['계산항목', '올림/내림/반올림', '소수점자릿수']]],
|
||||
['text' => '여유 수량(LOSS) 적용 품목과 비율은?', 'type' => 'table_input', 'options' => ['columns' => ['품목명', 'LOSS율(%)', '비고']]],
|
||||
],
|
||||
],
|
||||
[
|
||||
'name' => '수식 검증',
|
||||
'questions' => [
|
||||
['text' => '실제 견적서에서 수량 계산 예시를 보여주세요 (W=3000, H=2500일 때)', 'type' => 'table_input', 'options' => ['columns' => ['부품명', '수식', '계산결과', '단위']], 'is_required' => true],
|
||||
['text' => '수식에 사용하는 함수가 있나요? (SUM, CEIL, ROUND 등)', 'type' => 'multi_select', 'options' => ['choices' => ['CEIL (올림)', 'FLOOR (내림)', 'ROUND (반올림)', 'MAX', 'MIN', 'IF 조건문', 'SUM']]],
|
||||
['text' => '조건에 따라 수식이 달라지는 경우가 있나요?', 'type' => 'text', 'ai_hint' => '예: 폭이 3000 초과이면 분할 계산'],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
// ================================================================
|
||||
// Domain 7: 조건부 로직
|
||||
// ================================================================
|
||||
'conditional_logic' => [
|
||||
'name' => '조건부 로직',
|
||||
'description' => '범위/매핑 기반 부품 자동 선택 규칙',
|
||||
'templates' => [
|
||||
[
|
||||
'name' => '범위 기반 선택',
|
||||
'questions' => [
|
||||
['text' => '무게 범위별 모터 용량 선택표를 작성해주세요', 'type' => 'price_table', 'options' => ['columns' => ['범위 시작(kg)', '범위 끝(kg)', '모터용량', '비고']], 'is_required' => true],
|
||||
['text' => '크기 범위별 부품 자동 선택 규칙이 있나요?', 'type' => 'table_input', 'options' => ['columns' => ['조건(변수)', '범위', '선택부품', '비고']]],
|
||||
['text' => '브라켓 크기 결정 기준은?', 'type' => 'table_input', 'options' => ['columns' => ['조건', '범위', '브라켓 규격']]],
|
||||
],
|
||||
],
|
||||
[
|
||||
'name' => '매핑 기반 선택',
|
||||
'questions' => [
|
||||
['text' => '제품 모델 → 기본 부품 세트 매핑표', 'type' => 'table_input', 'options' => ['columns' => ['제품모델', '기본부품1', '기본부품2', '기본부품3']]],
|
||||
['text' => '설치 유형 → 추가 부품 매핑표', 'type' => 'table_input', 'options' => ['columns' => ['설치유형', '추가부품', '수량', '비고']]],
|
||||
['text' => '제어기 유형 → 부속품 매핑표', 'type' => 'table_input', 'options' => ['columns' => ['제어기유형', '부속품1', '부속품2', '부속품3']]],
|
||||
['text' => '기타 조건부 자동 선택 규칙', 'type' => 'text', 'ai_hint' => '위 항목에 해당하지 않는 조건-결과 매핑'],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
// ================================================================
|
||||
// Domain 8: 견적서 양식
|
||||
// ================================================================
|
||||
'quote_format' => [
|
||||
'name' => '견적서 양식',
|
||||
'description' => '출력 양식, 항목 그룹, 소계/합계 구조',
|
||||
'templates' => [
|
||||
[
|
||||
'name' => '출력 양식',
|
||||
'questions' => [
|
||||
['text' => '현재 사용 중인 견적서 양식을 업로드해주세요', 'type' => 'file_upload', 'is_required' => true],
|
||||
['text' => '견적서에 표시되는 항목 그룹은? (재료비, 노무비, 설치비 등)', 'type' => 'multi_select', 'options' => ['choices' => ['재료비', '노무비', '경비', '설치비', '운반비', '이윤', '부가세']]],
|
||||
['text' => '소계/합계 계산 구조를 설명해주세요', 'type' => 'text', 'ai_hint' => '항목 그룹별 소계와 최종 합계의 관계'],
|
||||
['text' => '할인 적용 방식은? (일괄? 항목별?)', 'type' => 'select', 'options' => ['choices' => ['일괄 할인', '항목별 할인', '할인 없음', '협의 할인']]],
|
||||
['text' => '부가세 표시 방식은? (별도? 포함?)', 'type' => 'select', 'options' => ['choices' => ['별도 표시', '포함 표시', '선택 가능']]],
|
||||
['text' => '견적서에 표시하지 않는 내부 관리 항목은?', 'type' => 'text'],
|
||||
['text' => '견적 번호 체계를 알려주세요', 'type' => 'text', 'ai_hint' => '예: Q-2026-001 형식'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'name' => '특수 요구사항',
|
||||
'questions' => [
|
||||
['text' => '산출내역서(세부 내역)를 별도로 제공하나요?', 'type' => 'checkbox'],
|
||||
['text' => '위치별(층/부호) 개별 산출이 필요한가요?', 'type' => 'checkbox'],
|
||||
['text' => '일괄 산출(여러 위치 합산)을 사용하나요?', 'type' => 'checkbox'],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1504,6 +1504,25 @@
|
||||
Route::get('/api/sessions/{id}', [\App\Http\Controllers\Sales\InterviewScenarioController::class, 'showSession'])->name('api.sessions.show');
|
||||
Route::post('/api/sessions/toggle-answer', [\App\Http\Controllers\Sales\InterviewScenarioController::class, 'toggleAnswer'])->name('api.sessions.toggle-answer');
|
||||
Route::post('/api/sessions/{id}/complete', [\App\Http\Controllers\Sales\InterviewScenarioController::class, 'completeSession'])->name('api.sessions.complete');
|
||||
Route::post('/api/sessions/save-answer', [\App\Http\Controllers\Sales\InterviewScenarioController::class, 'saveAnswer'])->name('api.sessions.save-answer');
|
||||
// 프로젝트 API
|
||||
Route::get('/api/projects', [\App\Http\Controllers\Sales\InterviewScenarioController::class, 'projects'])->name('api.projects');
|
||||
Route::post('/api/projects', [\App\Http\Controllers\Sales\InterviewScenarioController::class, 'storeProject'])->name('api.projects.store');
|
||||
Route::get('/api/projects/{id}', [\App\Http\Controllers\Sales\InterviewScenarioController::class, 'showProject'])->name('api.projects.show');
|
||||
Route::put('/api/projects/{id}', [\App\Http\Controllers\Sales\InterviewScenarioController::class, 'updateProject'])->name('api.projects.update');
|
||||
Route::delete('/api/projects/{id}', [\App\Http\Controllers\Sales\InterviewScenarioController::class, 'destroyProject'])->name('api.projects.destroy');
|
||||
Route::get('/api/projects/{id}/tree', [\App\Http\Controllers\Sales\InterviewScenarioController::class, 'projectTree'])->name('api.projects.tree');
|
||||
Route::post('/api/projects/{id}/progress', [\App\Http\Controllers\Sales\InterviewScenarioController::class, 'projectProgress'])->name('api.projects.progress');
|
||||
// 첨부파일 API
|
||||
Route::get('/api/projects/{projectId}/attachments', [\App\Http\Controllers\Sales\InterviewScenarioController::class, 'attachments'])->name('api.attachments');
|
||||
Route::post('/api/projects/{projectId}/attachments', [\App\Http\Controllers\Sales\InterviewScenarioController::class, 'uploadAttachment'])->name('api.attachments.upload');
|
||||
Route::delete('/api/attachments/{id}', [\App\Http\Controllers\Sales\InterviewScenarioController::class, 'destroyAttachment'])->name('api.attachments.destroy');
|
||||
// 지식 API
|
||||
Route::get('/api/projects/{projectId}/knowledge', [\App\Http\Controllers\Sales\InterviewScenarioController::class, 'knowledge'])->name('api.knowledge');
|
||||
Route::post('/api/projects/{projectId}/knowledge', [\App\Http\Controllers\Sales\InterviewScenarioController::class, 'storeKnowledge'])->name('api.knowledge.store');
|
||||
Route::put('/api/knowledge/{id}', [\App\Http\Controllers\Sales\InterviewScenarioController::class, 'updateKnowledge'])->name('api.knowledge.update');
|
||||
Route::post('/api/knowledge/{id}/verify', [\App\Http\Controllers\Sales\InterviewScenarioController::class, 'verifyKnowledge'])->name('api.knowledge.verify');
|
||||
Route::delete('/api/knowledge/{id}', [\App\Http\Controllers\Sales\InterviewScenarioController::class, 'destroyKnowledge'])->name('api.knowledge.destroy');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user