feat:레거시 영업관리 시스템 MNG 마이그레이션
- 영업/매니저 시나리오 모달 구현 (6단계 체크리스트) - 상담 기록 기능 (텍스트, 음성, 첨부파일) - 음성 녹음 + Speech-to-Text 변환 - 첨부파일 Drag & Drop 업로드 - 매니저 지정 드롭다운 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
286
app/Http/Controllers/Sales/ConsultationController.php
Normal file
286
app/Http/Controllers/Sales/ConsultationController.php
Normal file
@@ -0,0 +1,286 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Sales;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Tenants\Tenant;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* 상담 기록 관리 컨트롤러
|
||||
*
|
||||
* 테넌트별 상담 기록(텍스트, 음성, 파일)을 관리합니다.
|
||||
*/
|
||||
class ConsultationController extends Controller
|
||||
{
|
||||
/**
|
||||
* 상담 기록 목록 (HTMX 부분 뷰)
|
||||
*/
|
||||
public function index(int $tenantId, Request $request): View
|
||||
{
|
||||
$tenant = Tenant::findOrFail($tenantId);
|
||||
$scenarioType = $request->input('scenario_type', 'sales');
|
||||
$stepId = $request->input('step_id');
|
||||
|
||||
// 캐시에서 상담 기록 조회
|
||||
$cacheKey = "consultations:{$tenantId}:{$scenarioType}";
|
||||
$consultations = cache()->get($cacheKey, []);
|
||||
|
||||
// 특정 단계 필터링
|
||||
if ($stepId) {
|
||||
$consultations = array_filter($consultations, fn($c) => ($c['step_id'] ?? null) == $stepId);
|
||||
}
|
||||
|
||||
// 최신순 정렬
|
||||
usort($consultations, fn($a, $b) => strtotime($b['created_at']) - strtotime($a['created_at']));
|
||||
|
||||
return view('sales.modals.consultation-log', [
|
||||
'tenant' => $tenant,
|
||||
'consultations' => $consultations,
|
||||
'scenarioType' => $scenarioType,
|
||||
'stepId' => $stepId,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 텍스트 상담 기록 저장
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'tenant_id' => 'required|integer|exists:tenants,id',
|
||||
'scenario_type' => 'required|in:sales,manager',
|
||||
'step_id' => 'nullable|integer',
|
||||
'content' => 'required|string|max:5000',
|
||||
]);
|
||||
|
||||
$tenantId = $request->input('tenant_id');
|
||||
$scenarioType = $request->input('scenario_type');
|
||||
$stepId = $request->input('step_id');
|
||||
$content = $request->input('content');
|
||||
|
||||
// 캐시 키
|
||||
$cacheKey = "consultations:{$tenantId}:{$scenarioType}";
|
||||
$consultations = cache()->get($cacheKey, []);
|
||||
|
||||
// 새 상담 기록 추가
|
||||
$consultation = [
|
||||
'id' => uniqid('cons_'),
|
||||
'type' => 'text',
|
||||
'content' => $content,
|
||||
'step_id' => $stepId,
|
||||
'created_by' => auth()->id(),
|
||||
'created_by_name' => auth()->user()->name,
|
||||
'created_at' => now()->toDateTimeString(),
|
||||
];
|
||||
|
||||
$consultations[] = $consultation;
|
||||
|
||||
// 캐시에 저장 (90일 유지)
|
||||
cache()->put($cacheKey, $consultations, now()->addDays(90));
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'consultation' => $consultation,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 상담 기록 삭제
|
||||
*/
|
||||
public function destroy(string $consultationId, Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'tenant_id' => 'required|integer|exists:tenants,id',
|
||||
'scenario_type' => 'required|in:sales,manager',
|
||||
]);
|
||||
|
||||
$tenantId = $request->input('tenant_id');
|
||||
$scenarioType = $request->input('scenario_type');
|
||||
|
||||
// 캐시 키
|
||||
$cacheKey = "consultations:{$tenantId}:{$scenarioType}";
|
||||
$consultations = cache()->get($cacheKey, []);
|
||||
|
||||
// 상담 기록 찾기 및 삭제
|
||||
$found = false;
|
||||
foreach ($consultations as $index => $consultation) {
|
||||
if ($consultation['id'] === $consultationId) {
|
||||
// 파일이 있으면 삭제
|
||||
if (isset($consultation['file_path'])) {
|
||||
Storage::delete($consultation['file_path']);
|
||||
}
|
||||
unset($consultations[$index]);
|
||||
$found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$found) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '상담 기록을 찾을 수 없습니다.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
// 캐시에 저장
|
||||
cache()->put($cacheKey, array_values($consultations), now()->addDays(90));
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 음성 파일 업로드
|
||||
*/
|
||||
public function uploadAudio(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'tenant_id' => 'required|integer|exists:tenants,id',
|
||||
'scenario_type' => 'required|in:sales,manager',
|
||||
'step_id' => 'nullable|integer',
|
||||
'audio' => 'required|file|mimes:webm,mp3,wav,ogg|max:51200', // 50MB
|
||||
'transcript' => 'nullable|string|max:10000',
|
||||
'duration' => 'nullable|integer',
|
||||
]);
|
||||
|
||||
$tenantId = $request->input('tenant_id');
|
||||
$scenarioType = $request->input('scenario_type');
|
||||
$stepId = $request->input('step_id');
|
||||
$transcript = $request->input('transcript');
|
||||
$duration = $request->input('duration');
|
||||
|
||||
// 파일 저장
|
||||
$file = $request->file('audio');
|
||||
$fileName = 'audio_' . now()->format('Ymd_His') . '_' . uniqid() . '.' . $file->getClientOriginalExtension();
|
||||
$path = $file->storeAs("tenant/consultations/{$tenantId}", $fileName, 'local');
|
||||
|
||||
// 캐시 키
|
||||
$cacheKey = "consultations:{$tenantId}:{$scenarioType}";
|
||||
$consultations = cache()->get($cacheKey, []);
|
||||
|
||||
// 새 상담 기록 추가
|
||||
$consultation = [
|
||||
'id' => uniqid('cons_'),
|
||||
'type' => 'audio',
|
||||
'file_path' => $path,
|
||||
'file_name' => $fileName,
|
||||
'transcript' => $transcript,
|
||||
'duration' => $duration,
|
||||
'step_id' => $stepId,
|
||||
'created_by' => auth()->id(),
|
||||
'created_by_name' => auth()->user()->name,
|
||||
'created_at' => now()->toDateTimeString(),
|
||||
];
|
||||
|
||||
$consultations[] = $consultation;
|
||||
|
||||
// 캐시에 저장
|
||||
cache()->put($cacheKey, $consultations, now()->addDays(90));
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'consultation' => $consultation,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 첨부파일 업로드
|
||||
*/
|
||||
public function uploadFile(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'tenant_id' => 'required|integer|exists:tenants,id',
|
||||
'scenario_type' => 'required|in:sales,manager',
|
||||
'step_id' => 'nullable|integer',
|
||||
'file' => 'required|file|max:20480', // 20MB
|
||||
]);
|
||||
|
||||
$tenantId = $request->input('tenant_id');
|
||||
$scenarioType = $request->input('scenario_type');
|
||||
$stepId = $request->input('step_id');
|
||||
|
||||
// 파일 저장
|
||||
$file = $request->file('file');
|
||||
$originalName = $file->getClientOriginalName();
|
||||
$fileName = now()->format('Ymd_His') . '_' . uniqid() . '_' . $originalName;
|
||||
$path = $file->storeAs("tenant/attachments/{$tenantId}", $fileName, 'local');
|
||||
|
||||
// 캐시 키
|
||||
$cacheKey = "consultations:{$tenantId}:{$scenarioType}";
|
||||
$consultations = cache()->get($cacheKey, []);
|
||||
|
||||
// 새 상담 기록 추가
|
||||
$consultation = [
|
||||
'id' => uniqid('cons_'),
|
||||
'type' => 'file',
|
||||
'file_path' => $path,
|
||||
'file_name' => $originalName,
|
||||
'file_size' => $file->getSize(),
|
||||
'file_type' => $file->getMimeType(),
|
||||
'step_id' => $stepId,
|
||||
'created_by' => auth()->id(),
|
||||
'created_by_name' => auth()->user()->name,
|
||||
'created_at' => now()->toDateTimeString(),
|
||||
];
|
||||
|
||||
$consultations[] = $consultation;
|
||||
|
||||
// 캐시에 저장
|
||||
cache()->put($cacheKey, $consultations, now()->addDays(90));
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'consultation' => $consultation,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 삭제
|
||||
*/
|
||||
public function deleteFile(string $fileId, Request $request): JsonResponse
|
||||
{
|
||||
return $this->destroy($fileId, $request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 다운로드 URL 생성
|
||||
*/
|
||||
public function getDownloadUrl(string $consultationId, Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'tenant_id' => 'required|integer|exists:tenants,id',
|
||||
'scenario_type' => 'required|in:sales,manager',
|
||||
]);
|
||||
|
||||
$tenantId = $request->input('tenant_id');
|
||||
$scenarioType = $request->input('scenario_type');
|
||||
|
||||
// 캐시 키
|
||||
$cacheKey = "consultations:{$tenantId}:{$scenarioType}";
|
||||
$consultations = cache()->get($cacheKey, []);
|
||||
|
||||
// 상담 기록 찾기
|
||||
$consultation = collect($consultations)->firstWhere('id', $consultationId);
|
||||
|
||||
if (!$consultation || !isset($consultation['file_path'])) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '파일을 찾을 수 없습니다.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
// 임시 다운로드 URL 생성 (5분 유효)
|
||||
$url = Storage::temporaryUrl($consultation['file_path'], now()->addMinutes(5));
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'url' => $url,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Tenants\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
@@ -114,4 +116,69 @@ private function getDashboardData(Request $request): array
|
||||
'endDate'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 매니저 지정 변경
|
||||
*/
|
||||
public function assignManager(int $tenantId, Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'manager_id' => 'required|integer',
|
||||
]);
|
||||
|
||||
$tenant = Tenant::findOrFail($tenantId);
|
||||
$managerId = $request->input('manager_id');
|
||||
|
||||
// 캐시 키
|
||||
$cacheKey = "tenant_manager:{$tenantId}";
|
||||
|
||||
if ($managerId === 0) {
|
||||
// 본인으로 설정 (현재 로그인 사용자)
|
||||
$manager = auth()->user();
|
||||
cache()->put($cacheKey, [
|
||||
'id' => $manager->id,
|
||||
'name' => $manager->name,
|
||||
'is_self' => true,
|
||||
], now()->addDays(365));
|
||||
} else {
|
||||
// 특정 매니저 지정
|
||||
$manager = User::find($managerId);
|
||||
if (!$manager) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '매니저를 찾을 수 없습니다.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
cache()->put($cacheKey, [
|
||||
'id' => $manager->id,
|
||||
'name' => $manager->name,
|
||||
'is_self' => $manager->id === auth()->id(),
|
||||
], now()->addDays(365));
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'manager' => [
|
||||
'id' => $manager->id,
|
||||
'name' => $manager->name,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 매니저 목록 조회 (드롭다운용)
|
||||
*/
|
||||
public function getManagers(Request $request): JsonResponse
|
||||
{
|
||||
// HQ 테넌트의 사용자 중 매니저 역할이 있는 사용자 조회
|
||||
$managers = User::whereHas('tenants', function ($query) {
|
||||
$query->where('tenant_type', 'HQ');
|
||||
})->get(['id', 'name', 'email']);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'managers' => $managers,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
212
app/Http/Controllers/Sales/SalesScenarioController.php
Normal file
212
app/Http/Controllers/Sales/SalesScenarioController.php
Normal file
@@ -0,0 +1,212 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Sales;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Tenants\Tenant;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* 영업 시나리오 관리 컨트롤러
|
||||
*
|
||||
* 영업 진행 및 매니저 상담 프로세스의 시나리오 모달과 체크리스트를 관리합니다.
|
||||
*/
|
||||
class SalesScenarioController extends Controller
|
||||
{
|
||||
/**
|
||||
* 영업 시나리오 모달 뷰
|
||||
*/
|
||||
public function salesScenario(int $tenantId, Request $request): View|Response
|
||||
{
|
||||
$tenant = Tenant::findOrFail($tenantId);
|
||||
$steps = config('sales_scenario.sales_steps');
|
||||
$currentStep = (int) $request->input('step', 1);
|
||||
$icons = config('sales_scenario.icons');
|
||||
|
||||
// 체크리스트 진행 상태 조회
|
||||
$progress = $this->getChecklistProgress($tenantId, 'sales');
|
||||
|
||||
// HTMX 요청이면 단계 콘텐츠만 반환
|
||||
if ($request->header('HX-Request') && $request->has('step')) {
|
||||
return view('sales.modals.scenario-step', [
|
||||
'tenant' => $tenant,
|
||||
'steps' => $steps,
|
||||
'currentStep' => $currentStep,
|
||||
'step' => collect($steps)->firstWhere('id', $currentStep),
|
||||
'progress' => $progress,
|
||||
'scenarioType' => 'sales',
|
||||
'icons' => $icons,
|
||||
]);
|
||||
}
|
||||
|
||||
return view('sales.modals.scenario-modal', [
|
||||
'tenant' => $tenant,
|
||||
'steps' => $steps,
|
||||
'currentStep' => $currentStep,
|
||||
'progress' => $progress,
|
||||
'scenarioType' => 'sales',
|
||||
'icons' => $icons,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 매니저 시나리오 모달 뷰
|
||||
*/
|
||||
public function managerScenario(int $tenantId, Request $request): View|Response
|
||||
{
|
||||
$tenant = Tenant::findOrFail($tenantId);
|
||||
$steps = config('sales_scenario.manager_steps');
|
||||
$currentStep = (int) $request->input('step', 1);
|
||||
$icons = config('sales_scenario.icons');
|
||||
|
||||
// 체크리스트 진행 상태 조회
|
||||
$progress = $this->getChecklistProgress($tenantId, 'manager');
|
||||
|
||||
// HTMX 요청이면 단계 콘텐츠만 반환
|
||||
if ($request->header('HX-Request') && $request->has('step')) {
|
||||
return view('sales.modals.scenario-step', [
|
||||
'tenant' => $tenant,
|
||||
'steps' => $steps,
|
||||
'currentStep' => $currentStep,
|
||||
'step' => collect($steps)->firstWhere('id', $currentStep),
|
||||
'progress' => $progress,
|
||||
'scenarioType' => 'manager',
|
||||
'icons' => $icons,
|
||||
]);
|
||||
}
|
||||
|
||||
return view('sales.modals.scenario-modal', [
|
||||
'tenant' => $tenant,
|
||||
'steps' => $steps,
|
||||
'currentStep' => $currentStep,
|
||||
'progress' => $progress,
|
||||
'scenarioType' => 'manager',
|
||||
'icons' => $icons,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 체크리스트 항목 토글 (HTMX)
|
||||
*/
|
||||
public function toggleChecklist(Request $request): Response
|
||||
{
|
||||
$request->validate([
|
||||
'tenant_id' => 'required|integer|exists:tenants,id',
|
||||
'scenario_type' => 'required|in:sales,manager',
|
||||
'step_id' => 'required|integer',
|
||||
'checkpoint_id' => 'required|string',
|
||||
'checked' => 'required|boolean',
|
||||
]);
|
||||
|
||||
$tenantId = $request->input('tenant_id');
|
||||
$scenarioType = $request->input('scenario_type');
|
||||
$stepId = $request->input('step_id');
|
||||
$checkpointId = $request->input('checkpoint_id');
|
||||
$checked = $request->boolean('checked');
|
||||
|
||||
// 캐시 키 생성
|
||||
$cacheKey = "scenario_checklist:{$tenantId}:{$scenarioType}";
|
||||
|
||||
// 현재 체크리스트 상태 조회
|
||||
$checklist = cache()->get($cacheKey, []);
|
||||
|
||||
// 체크리스트 상태 업데이트
|
||||
$key = "{$stepId}_{$checkpointId}";
|
||||
if ($checked) {
|
||||
$checklist[$key] = [
|
||||
'checked_at' => now()->toDateTimeString(),
|
||||
'checked_by' => auth()->id(),
|
||||
];
|
||||
} else {
|
||||
unset($checklist[$key]);
|
||||
}
|
||||
|
||||
// 캐시에 저장 (30일 유지)
|
||||
cache()->put($cacheKey, $checklist, now()->addDays(30));
|
||||
|
||||
// 진행률 계산
|
||||
$progress = $this->calculateProgress($tenantId, $scenarioType);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'progress' => $progress,
|
||||
'checked' => $checked,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 진행률 조회
|
||||
*/
|
||||
public function getProgress(int $tenantId, string $type): Response
|
||||
{
|
||||
$progress = $this->calculateProgress($tenantId, $type);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'progress' => $progress,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 체크리스트 진행 상태 조회
|
||||
*/
|
||||
private function getChecklistProgress(int $tenantId, string $scenarioType): array
|
||||
{
|
||||
$cacheKey = "scenario_checklist:{$tenantId}:{$scenarioType}";
|
||||
|
||||
return cache()->get($cacheKey, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* 진행률 계산
|
||||
*/
|
||||
private function calculateProgress(int $tenantId, string $scenarioType): array
|
||||
{
|
||||
$steps = config("sales_scenario.{$scenarioType}_steps");
|
||||
$checklist = $this->getChecklistProgress($tenantId, $scenarioType);
|
||||
|
||||
$totalCheckpoints = 0;
|
||||
$completedCheckpoints = 0;
|
||||
$stepProgress = [];
|
||||
|
||||
foreach ($steps as $step) {
|
||||
$stepCompleted = 0;
|
||||
$stepTotal = count($step['checkpoints']);
|
||||
$totalCheckpoints += $stepTotal;
|
||||
|
||||
foreach ($step['checkpoints'] as $checkpoint) {
|
||||
$key = "{$step['id']}_{$checkpoint['id']}";
|
||||
if (isset($checklist[$key])) {
|
||||
$completedCheckpoints++;
|
||||
$stepCompleted++;
|
||||
}
|
||||
}
|
||||
|
||||
$stepProgress[$step['id']] = [
|
||||
'total' => $stepTotal,
|
||||
'completed' => $stepCompleted,
|
||||
'percentage' => $stepTotal > 0 ? round(($stepCompleted / $stepTotal) * 100) : 0,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'total' => $totalCheckpoints,
|
||||
'completed' => $completedCheckpoints,
|
||||
'percentage' => $totalCheckpoints > 0 ? round(($completedCheckpoints / $totalCheckpoints) * 100) : 0,
|
||||
'steps' => $stepProgress,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 단계의 체크포인트 체크 여부 확인
|
||||
*/
|
||||
public function isCheckpointChecked(int $tenantId, string $scenarioType, int $stepId, string $checkpointId): bool
|
||||
{
|
||||
$checklist = $this->getChecklistProgress($tenantId, $scenarioType);
|
||||
$key = "{$stepId}_{$checkpointId}";
|
||||
|
||||
return isset($checklist[$key]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user