From 4cf0be0ab6efd410d16b0379e374859a298be10c Mon Sep 17 00:00:00 2001 From: pro Date: Wed, 28 Jan 2026 14:39:53 +0900 Subject: [PATCH 001/129] =?UTF-8?q?refactor:=EA=B0=80=EB=A7=9D=EA=B3=A0?= =?UTF-8?q?=EA=B0=9D=20=EC=B6=94=EA=B0=80=EC=84=9C=EB=A5=98=20=EC=84=B9?= =?UTF-8?q?=EC=85=98=20=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20=ED=8C=8C=ED=8A=B8?= =?UTF-8?q?=EB=84=88=20=EC=84=9C=EB=A5=98=ED=83=80=EC=9E=85=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - prospects/create, edit, show에서 신분증/통장 사본 업로드 UI 제거 - SalesManagerDocument DOCUMENT_TYPES를 등본사본, 통장사본만 유지 - 계약서는 모두의싸인 통해 별도 처리 예정 Co-Authored-By: Claude Opus 4.5 --- app/Models/Sales/SalesManagerDocument.php | 6 +- .../views/sales/prospects/create.blade.php | 93 ------------------- .../views/sales/prospects/edit.blade.php | 49 ---------- .../views/sales/prospects/show.blade.php | 26 +----- 4 files changed, 4 insertions(+), 170 deletions(-) diff --git a/app/Models/Sales/SalesManagerDocument.php b/app/Models/Sales/SalesManagerDocument.php index 6897f351..d54479c6 100644 --- a/app/Models/Sales/SalesManagerDocument.php +++ b/app/Models/Sales/SalesManagerDocument.php @@ -37,13 +37,11 @@ class SalesManagerDocument extends Model /** * 문서 타입 목록 + * - 계약서는 모두의싸인을 통해 별도 처리 */ public const DOCUMENT_TYPES = [ - 'id_card' => '신분증', - 'business_license' => '사업자등록증', - 'contract' => '계약서', + 'resident_copy' => '등본사본', 'bank_account' => '통장사본', - 'other' => '기타', ]; /** diff --git a/resources/views/sales/prospects/create.blade.php b/resources/views/sales/prospects/create.blade.php index 2bc4cf55..aadc2a2b 100644 --- a/resources/views/sales/prospects/create.blade.php +++ b/resources/views/sales/prospects/create.blade.php @@ -112,8 +112,6 @@
@csrf - -
@@ -177,37 +175,6 @@ class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none foc

JPG, PNG 형식 (최대 5MB)

- -
-

추가 서류

-
-
- -
- - - - -

클릭하여 업로드

-
- -
- -
- -
- - - - -

클릭하여 업로드

-
- -
-
-

JPG, PNG 형식 (최대 5MB)

-
-
+
+ +
+
+ + + {{-- 상담 기록 목록 --}} +
+

상담 기록 ({{ count($consultations) }}건)

+ + @if(empty($consultations)) +
+ + + +

아직 상담 기록이 없습니다.

+
+ @else +
+ @foreach($consultations as $consultation) +
+ {{-- 삭제 버튼 --}} + + + {{-- 콘텐츠 --}} +
+ {{-- 타입 아이콘 --}} +
+ @if($consultation['type'] === 'text') + + + + @elseif($consultation['type'] === 'audio') + + + + @else + + + + @endif +
+ +
+ @if($consultation['type'] === 'text') +

{{ $consultation['content'] }}

+ @elseif($consultation['type'] === 'audio') +
+
+ 음성 녹음 + @if(isset($consultation['duration'])) + + {{ floor($consultation['duration'] / 60) }}:{{ str_pad($consultation['duration'] % 60, 2, '0', STR_PAD_LEFT) }} + + @endif +
+ @if(isset($consultation['transcript']) && $consultation['transcript']) +

"{{ $consultation['transcript'] }}"

+ @endif +
+ @else +
+ {{ $consultation['file_name'] }} + + {{ number_format(($consultation['file_size'] ?? 0) / 1024, 1) }} KB + +
+ @endif + + {{-- 메타 정보 --}} +
+ {{ $consultation['created_by_name'] }} + | + {{ \Carbon\Carbon::parse($consultation['created_at'])->format('Y-m-d H:i') }} +
+
+
+
+ @endforeach +
+ @endif +
+ + + diff --git a/resources/views/sales/modals/file-uploader.blade.php b/resources/views/sales/modals/file-uploader.blade.php new file mode 100644 index 00000000..60fd4175 --- /dev/null +++ b/resources/views/sales/modals/file-uploader.blade.php @@ -0,0 +1,260 @@ +{{-- 첨부파일 업로드 컴포넌트 --}} +
+

+ + + + 첨부파일 +

+ + {{-- Drag & Drop 영역 --}} +
+ + + + + + +

+ 파일을 여기에 드래그하거나 클릭하여 선택 +

+

+ 최대 20MB / PDF, 문서, 이미지, 압축파일 지원 +

+
+ + {{-- 업로드 대기 파일 목록 --}} +
+
업로드 대기
+ + + {{-- 업로드 버튼 --}} +
+ +
+
+
+ + diff --git a/resources/views/sales/modals/scenario-modal.blade.php b/resources/views/sales/modals/scenario-modal.blade.php new file mode 100644 index 00000000..76ca4df1 --- /dev/null +++ b/resources/views/sales/modals/scenario-modal.blade.php @@ -0,0 +1,185 @@ +{{-- 영업/매니저 시나리오 모달 --}} +
+ + {{-- 배경 오버레이 --}} +
+ + {{-- 모달 컨테이너 --}} +
+
+ + {{-- 모달 헤더 --}} +
+
+
+ @if($scenarioType === 'sales') + + + + @else + + + + @endif +
+
+

+ {{ $scenarioType === 'sales' ? '영업 전략 시나리오' : '매니저 상담 프로세스' }} +

+

{{ $tenant->company_name }}

+
+
+
+ {{-- 전체 진행률 --}} +
+ 진행률 + {{ $progress['percentage'] }}% +
+ {{-- 닫기 버튼 --}} + +
+
+ + {{-- 모달 바디 --}} +
+ {{-- 좌측 사이드바: 단계 네비게이션 --}} +
+
+

단계별 진행

+ +
+
+ + {{-- 우측 메인 영역: 단계별 콘텐츠 --}} +
+
+ @include('sales.modals.scenario-step', [ + 'step' => collect($steps)->firstWhere('id', $currentStep), + 'tenant' => $tenant, + 'scenarioType' => $scenarioType, + 'progress' => $progress, + 'icons' => $icons, + ]) +
+
+
+
+
+
+ +@push('scripts') + +@endpush diff --git a/resources/views/sales/modals/scenario-step.blade.php b/resources/views/sales/modals/scenario-step.blade.php new file mode 100644 index 00000000..3cefe0b5 --- /dev/null +++ b/resources/views/sales/modals/scenario-step.blade.php @@ -0,0 +1,184 @@ +{{-- 시나리오 단계별 체크리스트 --}} +@php + $step = $step ?? collect($steps)->firstWhere('id', $currentStep ?? 1); + $checkedItems = $progress[$step['id']] ?? []; +@endphp + +
+ {{-- 단계 헤더 --}} +
+
+ + {!! $icons[$step['icon']] ?? '' !!} + +
+
+
+ STEP {{ $step['id'] }} + {{ $step['subtitle'] }} +
+

{{ $step['title'] }}

+

{{ $step['description'] }}

+
+
+ + {{-- 매니저용 팁 (있는 경우) --}} + @if(isset($step['tips'])) +
+
+ + + +
+

매니저 TIP

+

{{ $step['tips'] }}

+
+
+
+ @endif + + {{-- 체크포인트 목록 --}} +
+ @foreach($step['checkpoints'] as $checkpoint) + @php + $checkKey = "{$step['id']}_{$checkpoint['id']}"; + $isChecked = isset($progress[$checkKey]); + @endphp +
+ + {{-- 체크포인트 헤더 --}} +
+ {{-- 체크박스 --}} + + + {{-- 제목 및 설명 --}} +
+

+ {{ $checkpoint['title'] }} +

+

{{ $checkpoint['detail'] }}

+
+ + {{-- 확장 아이콘 --}} + + + +
+ + {{-- 확장 콘텐츠 --}} +
+
+ {{-- 상세 설명 --}} +
+
상세 설명
+

{{ $checkpoint['detail'] }}

+
+ + {{-- PRO TIP --}} +
+
+
+ + + +
+
+

PRO TIP

+

{{ $checkpoint['pro_tip'] }}

+
+
+
+
+
+
+ @endforeach +
+ + {{-- 하단: 상담 기록 및 파일 영역 (마지막 단계에서만) --}} + @if($step['id'] === 6) +
+

상담 기록 및 첨부파일

+ + {{-- 상담 기록 --}} +
+
+
+
+
+
+
+
+
+
+
+ + {{-- 음성 녹음 --}} +
+ @include('sales.modals.voice-recorder', [ + 'tenant' => $tenant, + 'scenarioType' => $scenarioType, + 'stepId' => $step['id'], + ]) +
+ + {{-- 첨부파일 업로드 --}} +
+ @include('sales.modals.file-uploader', [ + 'tenant' => $tenant, + 'scenarioType' => $scenarioType, + 'stepId' => $step['id'], + ]) +
+
+ @endif + + {{-- 단계 이동 버튼 --}} +
+ @if($step['id'] > 1) + + @else +
+ @endif + + @if($step['id'] < count($steps)) + + @else + + @endif +
+
diff --git a/resources/views/sales/modals/voice-recorder.blade.php b/resources/views/sales/modals/voice-recorder.blade.php new file mode 100644 index 00000000..f6c1041a --- /dev/null +++ b/resources/views/sales/modals/voice-recorder.blade.php @@ -0,0 +1,363 @@ +{{-- 음성 녹음 컴포넌트 --}} +
+

+ + + + 음성 녹음 +

+ + {{-- 녹음 컨트롤 --}} +
+ {{-- 파형 시각화 --}} +
+ + {{-- 타이머 오버레이 --}} +
+ + 00:00 +
+
+ + {{-- 실시간 텍스트 변환 표시 --}} +
+

음성 인식 결과

+

+ + +

+
+ + {{-- 컨트롤 버튼 --}} +
+ {{-- 녹음 시작/중지 버튼 --}} + + + {{-- 저장 버튼 (녹음 완료 후) --}} + + + {{-- 취소 버튼 (녹음 완료 후) --}} + +
+ + {{-- 상태 메시지 --}} +

+ + {{-- 저장된 녹음 목록 안내 --}} +

+ 녹음 파일은 상담 기록에 자동으로 저장됩니다. +

+
+
+ + diff --git a/routes/web.php b/routes/web.php index a26ea93d..b1dc8bd2 100644 --- a/routes/web.php +++ b/routes/web.php @@ -791,4 +791,25 @@ // 영업 실적 관리 Route::resource('records', \App\Http\Controllers\Sales\SalesRecordController::class); + + // 영업 시나리오 관리 + Route::prefix('scenarios')->name('scenarios.')->group(function () { + Route::get('/{tenant}/sales', [\App\Http\Controllers\Sales\SalesScenarioController::class, 'salesScenario'])->name('sales'); + Route::get('/{tenant}/manager', [\App\Http\Controllers\Sales\SalesScenarioController::class, 'managerScenario'])->name('manager'); + Route::post('/checklist/toggle', [\App\Http\Controllers\Sales\SalesScenarioController::class, 'toggleChecklist'])->name('checklist.toggle'); + Route::get('/{tenant}/{type}/progress', [\App\Http\Controllers\Sales\SalesScenarioController::class, 'getProgress'])->name('progress'); + }); + + // 상담 기록 관리 + Route::prefix('consultations')->name('consultations.')->group(function () { + Route::get('/{tenant}', [\App\Http\Controllers\Sales\ConsultationController::class, 'index'])->name('index'); + Route::post('/', [\App\Http\Controllers\Sales\ConsultationController::class, 'store'])->name('store'); + Route::delete('/{consultation}', [\App\Http\Controllers\Sales\ConsultationController::class, 'destroy'])->name('destroy'); + Route::post('/upload-audio', [\App\Http\Controllers\Sales\ConsultationController::class, 'uploadAudio'])->name('upload-audio'); + Route::post('/upload-file', [\App\Http\Controllers\Sales\ConsultationController::class, 'uploadFile'])->name('upload-file'); + Route::delete('/file/{file}', [\App\Http\Controllers\Sales\ConsultationController::class, 'deleteFile'])->name('delete-file'); + }); + + // 매니저 지정 변경 + Route::post('/tenants/{tenant}/assign-manager', [\App\Http\Controllers\Sales\SalesDashboardController::class, 'assignManager'])->name('tenants.assign-manager'); }); From 329c58e63bb1cc6da9217a4972cd017a9d058c42 Mon Sep 17 00:00:00 2001 From: pro Date: Thu, 29 Jan 2026 06:42:32 +0900 Subject: [PATCH 047/129] =?UTF-8?q?refactor:=EC=98=81=EC=97=85=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EB=8D=B0=EC=9D=B4=ED=84=B0=EB=A5=BC=20DB=20?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=B8=94=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 모델 추가: SalesPartner, SalesTenantManagement, SalesScenarioChecklist, SalesConsultation - 모델 위치 이동: app/Models/ → app/Models/Sales/ - 컨트롤러 수정: 캐시 대신 DB 모델 사용 - 뷰 수정: Eloquent 모델 속성 사용 Co-Authored-By: Claude Opus 4.5 --- .../Sales/ConsultationController.php | 217 +++++---------- .../Sales/SalesDashboardController.php | 29 +- .../Sales/SalesScenarioController.php | 131 +++------ app/Models/Sales/SalesConsultation.php | 248 ++++++++++++++++++ app/Models/Sales/SalesPartner.php | 116 ++++++++ app/Models/Sales/SalesScenarioChecklist.php | 168 ++++++++++++ app/Models/Sales/SalesTenantManagement.php | 203 ++++++++++++++ app/Models/SalesScenarioChecklist.php | 37 --- .../partials/manager-dropdown.blade.php | 10 +- .../sales/modals/consultation-log.blade.php | 42 ++- .../sales/modals/scenario-step.blade.php | 7 +- 11 files changed, 889 insertions(+), 319 deletions(-) create mode 100644 app/Models/Sales/SalesConsultation.php create mode 100644 app/Models/Sales/SalesPartner.php create mode 100644 app/Models/Sales/SalesScenarioChecklist.php create mode 100644 app/Models/Sales/SalesTenantManagement.php delete mode 100644 app/Models/SalesScenarioChecklist.php diff --git a/app/Http/Controllers/Sales/ConsultationController.php b/app/Http/Controllers/Sales/ConsultationController.php index fea2eade..33a1e610 100644 --- a/app/Http/Controllers/Sales/ConsultationController.php +++ b/app/Http/Controllers/Sales/ConsultationController.php @@ -3,9 +3,9 @@ namespace App\Http\Controllers\Sales; use App\Http\Controllers\Controller; +use App\Models\Sales\SalesConsultation; 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; @@ -14,6 +14,7 @@ * 상담 기록 관리 컨트롤러 * * 테넌트별 상담 기록(텍스트, 음성, 파일)을 관리합니다. + * 데이터는 sales_consultations 테이블에 저장됩니다. */ class ConsultationController extends Controller { @@ -26,17 +27,8 @@ public function index(int $tenantId, Request $request): View $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'])); + // DB에서 상담 기록 조회 + $consultations = SalesConsultation::getByTenantAndType($tenantId, $scenarioType, $stepId); return view('sales.modals.consultation-log', [ 'tenant' => $tenant, @@ -58,77 +50,36 @@ public function store(Request $request): JsonResponse 'content' => 'required|string|max:5000', ]); - $tenantId = $request->input('tenant_id'); - $scenarioType = $request->input('scenario_type'); - $stepId = $request->input('step_id'); - $content = $request->input('content'); + $consultation = SalesConsultation::createText( + $request->input('tenant_id'), + $request->input('scenario_type'), + $request->input('step_id'), + $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)); + $consultation->load('creator'); return response()->json([ 'success' => true, - 'consultation' => $consultation, + 'consultation' => [ + 'id' => $consultation->id, + 'type' => $consultation->consultation_type, + 'content' => $consultation->content, + 'created_by_name' => $consultation->creator->name, + 'created_at' => $consultation->created_at->format('Y-m-d H:i'), + ], ]); } /** * 상담 기록 삭제 */ - public function destroy(string $consultationId, Request $request): JsonResponse + public function destroy(int $consultationId, Request $request): JsonResponse { - $request->validate([ - 'tenant_id' => 'required|integer|exists:tenants,id', - 'scenario_type' => 'required|in:sales,manager', - ]); + $consultation = SalesConsultation::findOrFail($consultationId); - $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)); + // 파일이 있으면 함께 삭제 + $consultation->deleteWithFile(); return response()->json([ 'success' => true, @@ -160,32 +111,32 @@ public function uploadAudio(Request $request): JsonResponse $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, []); + // DB에 저장 + $consultation = SalesConsultation::createAudio( + $tenantId, + $scenarioType, + $stepId, + $path, + $fileName, + $file->getSize(), + $transcript, + $duration + ); - // 새 상담 기록 추가 - $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)); + $consultation->load('creator'); return response()->json([ 'success' => true, - 'consultation' => $consultation, + 'consultation' => [ + 'id' => $consultation->id, + 'type' => $consultation->consultation_type, + 'file_name' => $consultation->file_name, + 'transcript' => $consultation->transcript, + 'duration' => $consultation->duration, + 'formatted_duration' => $consultation->formatted_duration, + 'created_by_name' => $consultation->creator->name, + 'created_at' => $consultation->created_at->format('Y-m-d H:i'), + ], ]); } @@ -211,76 +162,38 @@ public function uploadFile(Request $request): JsonResponse $fileName = now()->format('Ymd_His') . '_' . uniqid() . '_' . $originalName; $path = $file->storeAs("tenant/attachments/{$tenantId}", $fileName, 'local'); - // 캐시 키 - $cacheKey = "consultations:{$tenantId}:{$scenarioType}"; - $consultations = cache()->get($cacheKey, []); + // DB에 저장 + $consultation = SalesConsultation::createFile( + $tenantId, + $scenarioType, + $stepId, + $path, + $originalName, + $file->getSize(), + $file->getMimeType() + ); - // 새 상담 기록 추가 - $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)); + $consultation->load('creator'); return response()->json([ 'success' => true, - 'consultation' => $consultation, + 'consultation' => [ + 'id' => $consultation->id, + 'type' => $consultation->consultation_type, + 'file_name' => $consultation->file_name, + 'file_size' => $consultation->file_size, + 'formatted_file_size' => $consultation->formatted_file_size, + 'created_by_name' => $consultation->creator->name, + 'created_at' => $consultation->created_at->format('Y-m-d H:i'), + ], ]); } /** * 파일 삭제 */ - public function deleteFile(string $fileId, Request $request): JsonResponse + public function deleteFile(int $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, - ]); - } } diff --git a/app/Http/Controllers/Sales/SalesDashboardController.php b/app/Http/Controllers/Sales/SalesDashboardController.php index 5e4c41dc..289f15ed 100644 --- a/app/Http/Controllers/Sales/SalesDashboardController.php +++ b/app/Http/Controllers/Sales/SalesDashboardController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\Sales; use App\Http\Controllers\Controller; +use App\Models\Sales\SalesTenantManagement; use App\Models\Tenants\Tenant; use App\Models\User; use Illuminate\Http\JsonResponse; @@ -103,12 +104,20 @@ private function getDashboardData(Request $request): array ->orderBy('created_at', 'desc') ->get(); + // 각 테넌트의 영업 관리 정보 로드 + $tenantIds = $tenants->pluck('id')->toArray(); + $managements = SalesTenantManagement::whereIn('tenant_id', $tenantIds) + ->with('manager') + ->get() + ->keyBy('tenant_id'); + return compact( 'stats', 'commissionByRole', 'totalCommissionRatio', 'tenantStats', 'tenants', + 'managements', 'period', 'year', 'month', @@ -129,17 +138,15 @@ public function assignManager(int $tenantId, Request $request): JsonResponse $tenant = Tenant::findOrFail($tenantId); $managerId = $request->input('manager_id'); - // 캐시 키 - $cacheKey = "tenant_manager:{$tenantId}"; + // 테넌트 영업 관리 정보 조회 또는 생성 + $management = SalesTenantManagement::findOrCreateByTenant($tenantId); if ($managerId === 0) { // 본인으로 설정 (현재 로그인 사용자) $manager = auth()->user(); - cache()->put($cacheKey, [ - 'id' => $manager->id, - 'name' => $manager->name, - 'is_self' => true, - ], now()->addDays(365)); + $management->update([ + 'manager_user_id' => $manager->id, + ]); } else { // 특정 매니저 지정 $manager = User::find($managerId); @@ -150,11 +157,9 @@ public function assignManager(int $tenantId, Request $request): JsonResponse ], 404); } - cache()->put($cacheKey, [ - 'id' => $manager->id, - 'name' => $manager->name, - 'is_self' => $manager->id === auth()->id(), - ], now()->addDays(365)); + $management->update([ + 'manager_user_id' => $manager->id, + ]); } return response()->json([ diff --git a/app/Http/Controllers/Sales/SalesScenarioController.php b/app/Http/Controllers/Sales/SalesScenarioController.php index 43874a75..d2ec9559 100644 --- a/app/Http/Controllers/Sales/SalesScenarioController.php +++ b/app/Http/Controllers/Sales/SalesScenarioController.php @@ -3,15 +3,19 @@ namespace App\Http\Controllers\Sales; use App\Http\Controllers\Controller; +use App\Models\Sales\SalesScenarioChecklist; +use App\Models\Sales\SalesTenantManagement; use App\Models\Tenants\Tenant; use Illuminate\Http\Request; use Illuminate\Http\Response; +use Illuminate\Http\JsonResponse; use Illuminate\View\View; /** * 영업 시나리오 관리 컨트롤러 * * 영업 진행 및 매니저 상담 프로세스의 시나리오 모달과 체크리스트를 관리합니다. + * 데이터는 sales_scenario_checklists 테이블에 저장됩니다. */ class SalesScenarioController extends Controller { @@ -25,8 +29,14 @@ public function salesScenario(int $tenantId, Request $request): View|Response $currentStep = (int) $request->input('step', 1); $icons = config('sales_scenario.icons'); - // 체크리스트 진행 상태 조회 - $progress = $this->getChecklistProgress($tenantId, 'sales'); + // 테넌트 영업 관리 정보 조회 또는 생성 + $management = SalesTenantManagement::findOrCreateByTenant($tenantId); + + // 체크리스트 진행 상태 조회 (DB에서) + $progress = SalesScenarioChecklist::calculateProgress($tenantId, 'sales', $steps); + + // 진행률 업데이트 + $management->updateProgress('sales', $progress['percentage']); // HTMX 요청이면 단계 콘텐츠만 반환 if ($request->header('HX-Request') && $request->has('step')) { @@ -38,6 +48,7 @@ public function salesScenario(int $tenantId, Request $request): View|Response 'progress' => $progress, 'scenarioType' => 'sales', 'icons' => $icons, + 'management' => $management, ]); } @@ -48,6 +59,7 @@ public function salesScenario(int $tenantId, Request $request): View|Response 'progress' => $progress, 'scenarioType' => 'sales', 'icons' => $icons, + 'management' => $management, ]); } @@ -61,8 +73,14 @@ public function managerScenario(int $tenantId, Request $request): View|Response $currentStep = (int) $request->input('step', 1); $icons = config('sales_scenario.icons'); - // 체크리스트 진행 상태 조회 - $progress = $this->getChecklistProgress($tenantId, 'manager'); + // 테넌트 영업 관리 정보 조회 또는 생성 + $management = SalesTenantManagement::findOrCreateByTenant($tenantId); + + // 체크리스트 진행 상태 조회 (DB에서) + $progress = SalesScenarioChecklist::calculateProgress($tenantId, 'manager', $steps); + + // 진행률 업데이트 + $management->updateProgress('manager', $progress['percentage']); // HTMX 요청이면 단계 콘텐츠만 반환 if ($request->header('HX-Request') && $request->has('step')) { @@ -74,6 +92,7 @@ public function managerScenario(int $tenantId, Request $request): View|Response 'progress' => $progress, 'scenarioType' => 'manager', 'icons' => $icons, + 'management' => $management, ]); } @@ -84,13 +103,14 @@ public function managerScenario(int $tenantId, Request $request): View|Response 'progress' => $progress, 'scenarioType' => 'manager', 'icons' => $icons, + 'management' => $management, ]); } /** * 체크리스트 항목 토글 (HTMX) */ - public function toggleChecklist(Request $request): Response + public function toggleChecklist(Request $request): JsonResponse { $request->validate([ 'tenant_id' => 'required|integer|exists:tenants,id', @@ -106,28 +126,23 @@ public function toggleChecklist(Request $request): Response $checkpointId = $request->input('checkpoint_id'); $checked = $request->boolean('checked'); - // 캐시 키 생성 - $cacheKey = "scenario_checklist:{$tenantId}:{$scenarioType}"; + // 체크리스트 토글 (DB에 저장) + SalesScenarioChecklist::toggle( + $tenantId, + $scenarioType, + $stepId, + $checkpointId, + $checked, + auth()->id() + ); - // 현재 체크리스트 상태 조회 - $checklist = cache()->get($cacheKey, []); + // 진행률 재계산 + $steps = config("sales_scenario.{$scenarioType}_steps"); + $progress = SalesScenarioChecklist::calculateProgress($tenantId, $scenarioType, $steps); - // 체크리스트 상태 업데이트 - $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); + // 테넌트 영업 관리 정보 업데이트 + $management = SalesTenantManagement::findOrCreateByTenant($tenantId); + $management->updateProgress($scenarioType, $progress['percentage']); return response()->json([ 'success' => true, @@ -139,74 +154,14 @@ public function toggleChecklist(Request $request): Response /** * 진행률 조회 */ - public function getProgress(int $tenantId, string $type): Response + public function getProgress(int $tenantId, string $type): JsonResponse { - $progress = $this->calculateProgress($tenantId, $type); + $steps = config("sales_scenario.{$type}_steps"); + $progress = SalesScenarioChecklist::calculateProgress($tenantId, $type, $steps); 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]); - } } diff --git a/app/Models/Sales/SalesConsultation.php b/app/Models/Sales/SalesConsultation.php new file mode 100644 index 00000000..5f5dbf46 --- /dev/null +++ b/app/Models/Sales/SalesConsultation.php @@ -0,0 +1,248 @@ + 'integer', + 'file_size' => 'integer', + 'duration' => 'integer', + ]; + + /** + * 상담 유형 상수 + */ + const TYPE_TEXT = 'text'; + const TYPE_AUDIO = 'audio'; + const TYPE_FILE = 'file'; + + /** + * 테넌트 관계 + */ + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + /** + * 작성자 관계 + */ + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + /** + * 텍스트 상담 기록 생성 + */ + public static function createText(int $tenantId, string $scenarioType, ?int $stepId, string $content): self + { + return self::create([ + 'tenant_id' => $tenantId, + 'scenario_type' => $scenarioType, + 'step_id' => $stepId, + 'consultation_type' => self::TYPE_TEXT, + 'content' => $content, + 'created_by' => auth()->id(), + ]); + } + + /** + * 음성 상담 기록 생성 + */ + public static function createAudio( + int $tenantId, + string $scenarioType, + ?int $stepId, + string $filePath, + string $fileName, + int $fileSize, + ?string $transcript = null, + ?int $duration = null + ): self { + return self::create([ + 'tenant_id' => $tenantId, + 'scenario_type' => $scenarioType, + 'step_id' => $stepId, + 'consultation_type' => self::TYPE_AUDIO, + 'file_path' => $filePath, + 'file_name' => $fileName, + 'file_size' => $fileSize, + 'file_type' => 'audio/webm', + 'transcript' => $transcript, + 'duration' => $duration, + 'created_by' => auth()->id(), + ]); + } + + /** + * 파일 상담 기록 생성 + */ + public static function createFile( + int $tenantId, + string $scenarioType, + ?int $stepId, + string $filePath, + string $fileName, + int $fileSize, + string $fileType + ): self { + return self::create([ + 'tenant_id' => $tenantId, + 'scenario_type' => $scenarioType, + 'step_id' => $stepId, + 'consultation_type' => self::TYPE_FILE, + 'file_path' => $filePath, + 'file_name' => $fileName, + 'file_size' => $fileSize, + 'file_type' => $fileType, + 'created_by' => auth()->id(), + ]); + } + + /** + * 파일 삭제 (storage 포함) + */ + public function deleteWithFile(): bool + { + if ($this->file_path && Storage::disk('local')->exists($this->file_path)) { + Storage::disk('local')->delete($this->file_path); + } + + return $this->delete(); + } + + /** + * 포맷된 duration Accessor + */ + public function getFormattedDurationAttribute(): ?string + { + if (!$this->duration) { + return null; + } + + $minutes = floor($this->duration / 60); + $seconds = $this->duration % 60; + + return sprintf('%02d:%02d', $minutes, $seconds); + } + + /** + * 포맷된 file size Accessor + */ + public function getFormattedFileSizeAttribute(): ?string + { + if (!$this->file_size) { + return null; + } + + if ($this->file_size < 1024) { + return $this->file_size . ' B'; + } elseif ($this->file_size < 1024 * 1024) { + return round($this->file_size / 1024, 1) . ' KB'; + } else { + return round($this->file_size / (1024 * 1024), 1) . ' MB'; + } + } + + /** + * 테넌트 + 시나리오 타입으로 조회 + */ + public static function getByTenantAndType(int $tenantId, string $scenarioType, ?int $stepId = null) + { + $query = self::where('tenant_id', $tenantId) + ->where('scenario_type', $scenarioType) + ->with('creator') + ->orderBy('created_at', 'desc'); + + if ($stepId !== null) { + $query->where('step_id', $stepId); + } + + return $query->get(); + } + + /** + * 시나리오 타입 스코프 + */ + public function scopeByScenarioType($query, string $type) + { + return $query->where('scenario_type', $type); + } + + /** + * 상담 유형 스코프 + */ + public function scopeByType($query, string $type) + { + return $query->where('consultation_type', $type); + } + + /** + * 텍스트만 스코프 + */ + public function scopeTextOnly($query) + { + return $query->where('consultation_type', self::TYPE_TEXT); + } + + /** + * 오디오만 스코프 + */ + public function scopeAudioOnly($query) + { + return $query->where('consultation_type', self::TYPE_AUDIO); + } + + /** + * 파일만 스코프 + */ + public function scopeFileOnly($query) + { + return $query->where('consultation_type', self::TYPE_FILE); + } +} diff --git a/app/Models/Sales/SalesPartner.php b/app/Models/Sales/SalesPartner.php new file mode 100644 index 00000000..627be054 --- /dev/null +++ b/app/Models/Sales/SalesPartner.php @@ -0,0 +1,116 @@ + 'decimal:2', + 'manager_commission_rate' => 'decimal:2', + 'total_contracts' => 'integer', + 'total_commission' => 'decimal:2', + 'approved_at' => 'datetime', + ]; + + /** + * 연결된 사용자 + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * 승인자 + */ + public function approver(): BelongsTo + { + return $this->belongsTo(User::class, 'approved_by'); + } + + /** + * 담당 테넌트 관리 목록 + */ + public function tenantManagements(): HasMany + { + return $this->hasMany(SalesTenantManagement::class, 'sales_partner_id'); + } + + /** + * 파트너 코드 자동 생성 + */ + public static function generatePartnerCode(): string + { + $prefix = 'SP'; + $year = now()->format('y'); + $lastPartner = self::whereYear('created_at', now()->year) + ->orderBy('id', 'desc') + ->first(); + + $sequence = $lastPartner ? (int) substr($lastPartner->partner_code, -4) + 1 : 1; + + return $prefix . $year . str_pad($sequence, 4, '0', STR_PAD_LEFT); + } + + /** + * 활성 파트너 스코프 + */ + public function scopeActive($query) + { + return $query->where('status', 'active'); + } + + /** + * 승인 대기 스코프 + */ + public function scopePending($query) + { + return $query->where('status', 'pending'); + } +} diff --git a/app/Models/Sales/SalesScenarioChecklist.php b/app/Models/Sales/SalesScenarioChecklist.php new file mode 100644 index 00000000..0356f695 --- /dev/null +++ b/app/Models/Sales/SalesScenarioChecklist.php @@ -0,0 +1,168 @@ + 'integer', + 'checkpoint_index' => 'integer', + 'is_checked' => 'boolean', + 'checked_at' => 'datetime', + ]; + + /** + * 테넌트 관계 + */ + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + /** + * 체크한 사용자 관계 + */ + public function checkedByUser(): BelongsTo + { + return $this->belongsTo(User::class, 'checked_by'); + } + + /** + * 사용자 관계 (하위 호환성) + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id'); + } + + /** + * 체크포인트 토글 + */ + public static function toggle(int $tenantId, string $scenarioType, int $stepId, string $checkpointId, bool $checked, ?int $userId = null): self + { + $checklist = self::firstOrNew([ + 'tenant_id' => $tenantId, + 'scenario_type' => $scenarioType, + 'step_id' => $stepId, + 'checkpoint_id' => $checkpointId, + ]); + + $checklist->is_checked = $checked; + $checklist->checked_at = $checked ? now() : null; + $checklist->checked_by = $checked ? ($userId ?? auth()->id()) : null; + $checklist->save(); + + return $checklist; + } + + /** + * 특정 테넌트/시나리오의 체크리스트 조회 + */ + public static function getChecklist(int $tenantId, string $scenarioType): array + { + $items = self::where('tenant_id', $tenantId) + ->where('scenario_type', $scenarioType) + ->where('is_checked', true) + ->get(); + + $result = []; + foreach ($items as $item) { + $key = "{$item->step_id}_{$item->checkpoint_id}"; + $result[$key] = [ + 'checked_at' => $item->checked_at?->toDateTimeString(), + 'checked_by' => $item->checked_by, + ]; + } + + return $result; + } + + /** + * 진행률 계산 + */ + public static function calculateProgress(int $tenantId, string $scenarioType, array $steps): array + { + $checklist = self::getChecklist($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 scopeByScenarioType($query, string $type) + { + return $query->where('scenario_type', $type); + } + + /** + * 체크된 항목만 스코프 + */ + public function scopeChecked($query) + { + return $query->where('is_checked', true); + } +} diff --git a/app/Models/Sales/SalesTenantManagement.php b/app/Models/Sales/SalesTenantManagement.php new file mode 100644 index 00000000..02a72e38 --- /dev/null +++ b/app/Models/Sales/SalesTenantManagement.php @@ -0,0 +1,203 @@ + 'integer', + 'manager_scenario_step' => 'integer', + 'membership_fee' => 'decimal:2', + 'sales_commission' => 'decimal:2', + 'manager_commission' => 'decimal:2', + 'sales_progress' => 'integer', + 'manager_progress' => 'integer', + 'first_contact_at' => 'datetime', + 'contracted_at' => 'datetime', + 'onboarding_completed_at' => 'datetime', + 'membership_paid_at' => 'datetime', + 'commission_paid_at' => 'datetime', + ]; + + /** + * 상태 상수 + */ + const STATUS_PROSPECT = 'prospect'; + const STATUS_APPROACH = 'approach'; + const STATUS_NEGOTIATION = 'negotiation'; + const STATUS_CONTRACTED = 'contracted'; + const STATUS_ONBOARDING = 'onboarding'; + const STATUS_ACTIVE = 'active'; + const STATUS_CHURNED = 'churned'; + + /** + * 상태 라벨 + */ + public static array $statusLabels = [ + self::STATUS_PROSPECT => '잠재 고객', + self::STATUS_APPROACH => '접근 중', + self::STATUS_NEGOTIATION => '협상 중', + self::STATUS_CONTRACTED => '계약 완료', + self::STATUS_ONBOARDING => '온보딩 중', + self::STATUS_ACTIVE => '활성 고객', + self::STATUS_CHURNED => '이탈', + ]; + + /** + * 테넌트 관계 + */ + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + /** + * 영업 담당자 관계 + */ + public function salesPartner(): BelongsTo + { + return $this->belongsTo(SalesPartner::class, 'sales_partner_id'); + } + + /** + * 관리 매니저 관계 + */ + public function manager(): BelongsTo + { + return $this->belongsTo(User::class, 'manager_user_id'); + } + + /** + * 체크리스트 관계 + */ + public function checklists(): HasMany + { + return $this->hasMany(SalesScenarioChecklist::class, 'tenant_id', 'tenant_id'); + } + + /** + * 상담 기록 관계 + */ + public function consultations(): HasMany + { + return $this->hasMany(SalesConsultation::class, 'tenant_id', 'tenant_id'); + } + + /** + * 테넌트 ID로 조회 또는 생성 + */ + public static function findOrCreateByTenant(int $tenantId): self + { + return self::firstOrCreate( + ['tenant_id' => $tenantId], + [ + 'status' => self::STATUS_PROSPECT, + 'sales_scenario_step' => 1, + 'manager_scenario_step' => 1, + ] + ); + } + + /** + * 진행률 업데이트 + */ + public function updateProgress(string $scenarioType, int $progress): void + { + $field = $scenarioType === 'sales' ? 'sales_progress' : 'manager_progress'; + $this->update([$field => $progress]); + } + + /** + * 현재 단계 업데이트 + */ + public function updateStep(string $scenarioType, int $step): void + { + $field = $scenarioType === 'sales' ? 'sales_scenario_step' : 'manager_scenario_step'; + $this->update([$field => $step]); + } + + /** + * 상태 라벨 Accessor + */ + public function getStatusLabelAttribute(): string + { + return self::$statusLabels[$this->status] ?? $this->status; + } + + /** + * 특정 상태 스코프 + */ + public function scopeByStatus($query, string $status) + { + return $query->where('status', $status); + } + + /** + * 계약 완료 스코프 + */ + public function scopeContracted($query) + { + return $query->whereIn('status', [ + self::STATUS_CONTRACTED, + self::STATUS_ONBOARDING, + self::STATUS_ACTIVE, + ]); + } +} diff --git a/app/Models/SalesScenarioChecklist.php b/app/Models/SalesScenarioChecklist.php deleted file mode 100644 index 7db52802..00000000 --- a/app/Models/SalesScenarioChecklist.php +++ /dev/null @@ -1,37 +0,0 @@ - 'integer', - 'checkpoint_index' => 'integer', - 'is_checked' => 'boolean', - ]; - - /** - * 사용자 관계 - */ - public function user(): BelongsTo - { - return $this->belongsTo(User::class); - } -} diff --git a/resources/views/sales/dashboard/partials/manager-dropdown.blade.php b/resources/views/sales/dashboard/partials/manager-dropdown.blade.php index d2435751..91b4cd6f 100644 --- a/resources/views/sales/dashboard/partials/manager-dropdown.blade.php +++ b/resources/views/sales/dashboard/partials/manager-dropdown.blade.php @@ -1,12 +1,12 @@ {{-- 매니저 드롭다운 컴포넌트 --}} @php - $cacheKey = "tenant_manager:{$tenant->id}"; - $assignedManager = cache()->get($cacheKey); - $isSelf = !$assignedManager || ($assignedManager['is_self'] ?? true); - $managerName = $assignedManager['name'] ?? '본인'; + $management = $managements[$tenant->id] ?? null; + $assignedManager = $management?->manager; + $isSelf = !$assignedManager || $assignedManager->id === auth()->id(); + $managerName = $assignedManager?->name ?? '본인'; @endphp -
+
{{-- 드롭다운 트리거 --}} - {{-- 매니저 목록 --}} -
- {{-- 본인 옵션 --}} + {{-- 구분선 (다른 매니저가 있을 때만) --}} + + + {{-- 다른 매니저 목록 --}} + - {{-- 구분선 --}} -
- - {{-- 다른 매니저 목록 --}} - - - {{-- 매니저가 없을 때 --}} -
+ {{-- 매니저가 없을 때 --}} +
+@endpush +@endonce From 92055a61878b510ab12e476a47871abd65bfa7eb Mon Sep 17 00:00:00 2001 From: pro Date: Thu, 29 Jan 2026 07:21:01 +0900 Subject: [PATCH 053/129] =?UTF-8?q?fix:=EB=A7=A4=EB=8B=88=EC=A0=80=20?= =?UTF-8?q?=EB=93=9C=EB=A1=AD=EB=8B=A4=EC=9A=B4=EC=9D=84=20=EC=9D=B8?= =?UTF-8?q?=EB=9D=BC=EC=9D=B8=20Alpine.js=EB=A1=9C=20=EC=99=84=EC=A0=84=20?= =?UTF-8?q?=EC=9E=AC=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 별도 스크립트 함수 대신 x-data 인라인 정의 - @click.away를 @click.outside로 수정 (올바른 Alpine 문법) - 스크립트 로드 순서 문제 해결 Co-Authored-By: Claude Opus 4.5 --- .../partials/manager-dropdown.blade.php | 111 +++++++----------- 1 file changed, 42 insertions(+), 69 deletions(-) diff --git a/resources/views/sales/dashboard/partials/manager-dropdown.blade.php b/resources/views/sales/dashboard/partials/manager-dropdown.blade.php index a2c705f5..f3c7c8bf 100644 --- a/resources/views/sales/dashboard/partials/manager-dropdown.blade.php +++ b/resources/views/sales/dashboard/partials/manager-dropdown.blade.php @@ -7,15 +7,54 @@ $assignedManager = $management?->manager; $isSelf = !$assignedManager || $assignedManager->id === auth()->id(); $managerName = $assignedManager?->name ?? '본인'; - // 매니저 목록 JSON (본인 제외는 컨트롤러에서 처리됨) $managersJson = $allManagers->map(fn($m) => ['id' => $m->id, 'name' => $m->name, 'email' => $m->email])->values()->toJson(); + $currentManagerJson = json_encode($assignedManager ? ['id' => $assignedManager->id, 'name' => $assignedManager->name, 'is_self' => $isSelf] : null); @endphp -
+
{{-- 드롭다운 트리거 --}}
- -@once -@push('scripts') - -@endpush -@endonce From 55994ab1166179fffaac71d9de10617dbed306ee Mon Sep 17 00:00:00 2001 From: pro Date: Thu, 29 Jan 2026 07:23:56 +0900 Subject: [PATCH 054/129] =?UTF-8?q?fix:Alpine.js=20CDN=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 레이아웃에 Alpine.js가 로드되지 않아 x-data 디렉티브가 동작하지 않음 - Alpine.js 3.x CDN 추가 Co-Authored-By: Claude Opus 4.5 --- resources/views/layouts/app.blade.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php index 60323cb9..4a7fa2a5 100644 --- a/resources/views/layouts/app.blade.php +++ b/resources/views/layouts/app.blade.php @@ -120,6 +120,8 @@ })(); + + -@endpush diff --git a/resources/views/sales/modals/scenario-step.blade.php b/resources/views/sales/modals/scenario-step.blade.php index 35a8e246..ba46eec8 100644 --- a/resources/views/sales/modals/scenario-step.blade.php +++ b/resources/views/sales/modals/scenario-step.blade.php @@ -47,15 +47,45 @@ $checkKey = "{$step['id']}_{$checkpoint['id']}"; $isChecked = isset($checklist[$checkKey]); @endphp -
{{-- 체크포인트 헤더 --}}
{{-- 체크박스 --}} -
{{-- 확장 콘텐츠 --}} -
+
{{-- 상세 설명 --}}
@@ -152,8 +189,11 @@ class="flex-shrink-0 w-6 h-6 rounded-full border-2 flex items-center justify-cen {{-- 단계 이동 버튼 --}}
@if($step['id'] > 1) - @else -
- - From dd86d70503e68ada5ec253c07d03253f21286462 Mon Sep 17 00:00:00 2001 From: pro Date: Thu, 29 Jan 2026 09:15:13 +0900 Subject: [PATCH 066/129] =?UTF-8?q?feat:=EC=9D=8C=EC=84=B1=20=EB=85=B9?= =?UTF-8?q?=EC=9D=8C=20GCS=20=EC=97=85=EB=A1=9C=EB=93=9C=20=EB=B0=8F=20?= =?UTF-8?q?=EB=8B=A4=EC=9A=B4=EB=A1=9C=EB=93=9C=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GoogleCloudStorageService 생성 (레거시 방식 JWT 인증) - 10MB 이상 파일은 Google Cloud Storage에 백업 (본사 연구용) - 오디오/파일 다운로드 라우트 추가 - voice-recorder.blade.php 인라인 x-data로 변경 (HTMX 호환) - SalesConsultation 모델에 gcs_uri 필드 추가 Co-Authored-By: Claude Opus 4.5 --- .../Sales/ConsultationController.php | 92 ++- app/Models/Sales/SalesConsultation.php | 15 +- app/Services/GoogleCloudStorageService.php | 247 ++++++++ .../sales/modals/voice-recorder.blade.php | 531 +++++++++--------- routes/web.php | 2 + 5 files changed, 601 insertions(+), 286 deletions(-) create mode 100644 app/Services/GoogleCloudStorageService.php diff --git a/app/Http/Controllers/Sales/ConsultationController.php b/app/Http/Controllers/Sales/ConsultationController.php index 33a1e610..bb45e877 100644 --- a/app/Http/Controllers/Sales/ConsultationController.php +++ b/app/Http/Controllers/Sales/ConsultationController.php @@ -5,8 +5,10 @@ use App\Http\Controllers\Controller; use App\Models\Sales\SalesConsultation; use App\Models\Tenants\Tenant; +use App\Services\GoogleCloudStorageService; use Illuminate\Http\Request; use Illuminate\Http\JsonResponse; +use Illuminate\Http\Response; use Illuminate\Support\Facades\Storage; use Illuminate\View\View; @@ -88,8 +90,10 @@ public function destroy(int $consultationId, Request $request): JsonResponse /** * 음성 파일 업로드 + * + * 10MB 이상 파일은 Google Cloud Storage에 업로드하여 본사 연구용으로 보관합니다. */ - public function uploadAudio(Request $request): JsonResponse + public function uploadAudio(Request $request, GoogleCloudStorageService $gcs): JsonResponse { $request->validate([ 'tenant_id' => 'required|integer|exists:tenants,id', @@ -109,18 +113,30 @@ public function uploadAudio(Request $request): JsonResponse // 파일 저장 $file = $request->file('audio'); $fileName = 'audio_' . now()->format('Ymd_His') . '_' . uniqid() . '.' . $file->getClientOriginalExtension(); - $path = $file->storeAs("tenant/consultations/{$tenantId}", $fileName, 'local'); + $localPath = $file->storeAs("tenant/consultations/{$tenantId}", $fileName, 'local'); + $fileSize = $file->getSize(); + + // 10MB 이상 파일은 GCS에도 업로드 (본사 연구용) + $gcsUri = null; + $maxLocalSize = 10 * 1024 * 1024; // 10MB + + if ($fileSize > $maxLocalSize && $gcs->isAvailable()) { + $gcsObjectName = "consultations/{$tenantId}/{$scenarioType}/{$fileName}"; + $localFullPath = Storage::disk('local')->path($localPath); + $gcsUri = $gcs->upload($localFullPath, $gcsObjectName); + } // DB에 저장 $consultation = SalesConsultation::createAudio( $tenantId, $scenarioType, $stepId, - $path, + $localPath, $fileName, - $file->getSize(), + $fileSize, $transcript, - $duration + $duration, + $gcsUri ); $consultation->load('creator'); @@ -136,6 +152,7 @@ public function uploadAudio(Request $request): JsonResponse 'formatted_duration' => $consultation->formatted_duration, 'created_by_name' => $consultation->creator->name, 'created_at' => $consultation->created_at->format('Y-m-d H:i'), + 'has_gcs' => !empty($gcsUri), ], ]); } @@ -196,4 +213,69 @@ public function deleteFile(int $fileId, Request $request): JsonResponse { return $this->destroy($fileId, $request); } + + /** + * 오디오 파일 다운로드 + */ + public function downloadAudio(int $consultationId, GoogleCloudStorageService $gcs): Response + { + $consultation = SalesConsultation::findOrFail($consultationId); + + if ($consultation->consultation_type !== 'audio') { + abort(400, '오디오 파일이 아닙니다.'); + } + + // GCS에 저장된 경우 서명된 URL로 리다이렉트 + if ($consultation->gcs_uri) { + $objectName = str_replace('gs://' . $gcs->getBucketName() . '/', '', $consultation->gcs_uri); + $signedUrl = $gcs->getSignedUrl($objectName, 60); + + if ($signedUrl) { + return response()->redirectTo($signedUrl); + } + } + + // 로컬 파일 다운로드 + $localPath = Storage::disk('local')->path($consultation->file_path); + + if (!file_exists($localPath)) { + abort(404, '파일을 찾을 수 없습니다.'); + } + + $extension = pathinfo($consultation->file_name, PATHINFO_EXTENSION) ?: 'webm'; + $mimeTypes = [ + 'webm' => 'audio/webm', + 'wav' => 'audio/wav', + 'mp3' => 'audio/mpeg', + 'ogg' => 'audio/ogg', + 'm4a' => 'audio/mp4' + ]; + $contentType = $mimeTypes[$extension] ?? 'audio/webm'; + + $downloadFileName = '상담녹음_' . $consultation->created_at->format('Ymd_His') . '.' . $extension; + + return response()->download($localPath, $downloadFileName, [ + 'Content-Type' => $contentType, + ]); + } + + /** + * 첨부파일 다운로드 + */ + public function downloadFile(int $consultationId): Response + { + $consultation = SalesConsultation::findOrFail($consultationId); + + if ($consultation->consultation_type !== 'file') { + abort(400, '첨부파일이 아닙니다.'); + } + + $localPath = Storage::disk('local')->path($consultation->file_path); + + if (!file_exists($localPath)) { + abort(404, '파일을 찾을 수 없습니다.'); + } + + return response()->download($localPath, $consultation->file_name); + } } diff --git a/app/Models/Sales/SalesConsultation.php b/app/Models/Sales/SalesConsultation.php index 5f5dbf46..0f141e40 100644 --- a/app/Models/Sales/SalesConsultation.php +++ b/app/Models/Sales/SalesConsultation.php @@ -44,6 +44,7 @@ class SalesConsultation extends Model 'file_type', 'transcript', 'duration', + 'gcs_uri', 'created_by', ]; @@ -93,6 +94,16 @@ public static function createText(int $tenantId, string $scenarioType, ?int $ste /** * 음성 상담 기록 생성 + * + * @param int $tenantId 테넌트 ID + * @param string $scenarioType 시나리오 타입 (sales/manager) + * @param int|null $stepId 단계 ID + * @param string $filePath 로컬 파일 경로 + * @param string $fileName 파일명 + * @param int $fileSize 파일 크기 + * @param string|null $transcript 음성 텍스트 변환 결과 + * @param int|null $duration 녹음 시간 (초) + * @param string|null $gcsUri GCS URI (본사 연구용 백업) */ public static function createAudio( int $tenantId, @@ -102,7 +113,8 @@ public static function createAudio( string $fileName, int $fileSize, ?string $transcript = null, - ?int $duration = null + ?int $duration = null, + ?string $gcsUri = null ): self { return self::create([ 'tenant_id' => $tenantId, @@ -115,6 +127,7 @@ public static function createAudio( 'file_type' => 'audio/webm', 'transcript' => $transcript, 'duration' => $duration, + 'gcs_uri' => $gcsUri, 'created_by' => auth()->id(), ]); } diff --git a/app/Services/GoogleCloudStorageService.php b/app/Services/GoogleCloudStorageService.php new file mode 100644 index 00000000..fa60cc9d --- /dev/null +++ b/app/Services/GoogleCloudStorageService.php @@ -0,0 +1,247 @@ +loadConfig(); + } + + /** + * GCS 설정 로드 + */ + private function loadConfig(): void + { + // GCS 버킷 설정 + $gcsConfigPath = base_path('../sales/apikey/gcs_config.txt'); + if (file_exists($gcsConfigPath)) { + $config = parse_ini_file($gcsConfigPath); + $this->bucketName = $config['bucket_name'] ?? null; + } + + // 서비스 계정 로드 + $serviceAccountPath = base_path('../sales/apikey/google_service_account.json'); + if (file_exists($serviceAccountPath)) { + $this->serviceAccount = json_decode(file_get_contents($serviceAccountPath), true); + } + } + + /** + * GCS가 사용 가능한지 확인 + */ + public function isAvailable(): bool + { + return $this->bucketName !== null && $this->serviceAccount !== null; + } + + /** + * GCS에 파일 업로드 + * + * @param string $filePath 로컬 파일 경로 + * @param string $objectName GCS에 저장할 객체 이름 + * @return string|null GCS URI (gs://bucket/object) 또는 실패 시 null + */ + public function upload(string $filePath, string $objectName): ?string + { + if (!$this->isAvailable()) { + Log::warning('GCS 업로드 실패: 설정되지 않음'); + return null; + } + + if (!file_exists($filePath)) { + Log::error('GCS 업로드 실패: 파일 없음 - ' . $filePath); + return null; + } + + // OAuth 2.0 토큰 생성 + $accessToken = $this->getAccessToken(); + if (!$accessToken) { + return null; + } + + // GCS에 파일 업로드 + $fileContent = file_get_contents($filePath); + $mimeType = mime_content_type($filePath) ?: 'application/octet-stream'; + + $uploadUrl = 'https://storage.googleapis.com/upload/storage/v1/b/' . + urlencode($this->bucketName) . '/o?uploadType=media&name=' . + urlencode($objectName); + + $ch = curl_init($uploadUrl); + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_HTTPHEADER, [ + 'Authorization: Bearer ' . $accessToken, + 'Content-Type: ' . $mimeType, + 'Content-Length: ' . strlen($fileContent) + ]); + curl_setopt($ch, CURLOPT_POSTFIELDS, $fileContent); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_TIMEOUT, 300); // 5분 타임아웃 + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $error = curl_error($ch); + curl_close($ch); + + if ($httpCode === 200) { + $gcsUri = 'gs://' . $this->bucketName . '/' . $objectName; + Log::info('GCS 업로드 성공: ' . $gcsUri); + return $gcsUri; + } + + Log::error('GCS 업로드 실패 (HTTP ' . $httpCode . '): ' . ($error ?: $response)); + return null; + } + + /** + * GCS에서 서명된 다운로드 URL 생성 + * + * @param string $objectName GCS 객체 이름 + * @param int $expiresInMinutes URL 유효 시간 (분) + * @return string|null 서명된 URL 또는 실패 시 null + */ + public function getSignedUrl(string $objectName, int $expiresInMinutes = 60): ?string + { + if (!$this->isAvailable()) { + return null; + } + + $expiration = time() + ($expiresInMinutes * 60); + $stringToSign = "GET\n\n\n{$expiration}\n/{$this->bucketName}/{$objectName}"; + + $privateKey = openssl_pkey_get_private($this->serviceAccount['private_key']); + if (!$privateKey) { + Log::error('GCS URL 서명 실패: 개인 키 읽기 오류'); + return null; + } + + openssl_sign($stringToSign, $signature, $privateKey, OPENSSL_ALGO_SHA256); + if (PHP_VERSION_ID < 80000) { + openssl_free_key($privateKey); + } + + $encodedSignature = urlencode(base64_encode($signature)); + $clientEmail = urlencode($this->serviceAccount['client_email']); + + return "https://storage.googleapis.com/{$this->bucketName}/{$objectName}" . + "?GoogleAccessId={$clientEmail}" . + "&Expires={$expiration}" . + "&Signature={$encodedSignature}"; + } + + /** + * GCS에서 파일 삭제 + * + * @param string $objectName GCS 객체 이름 + * @return bool 성공 여부 + */ + public function delete(string $objectName): bool + { + if (!$this->isAvailable()) { + return false; + } + + $accessToken = $this->getAccessToken(); + if (!$accessToken) { + return false; + } + + $deleteUrl = 'https://storage.googleapis.com/storage/v1/b/' . + urlencode($this->bucketName) . '/o/' . + urlencode($objectName); + + $ch = curl_init($deleteUrl); + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE'); + curl_setopt($ch, CURLOPT_HTTPHEADER, [ + 'Authorization: Bearer ' . $accessToken, + ]); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + return $httpCode === 204 || $httpCode === 200; + } + + /** + * OAuth 2.0 액세스 토큰 획득 + */ + private function getAccessToken(): ?string + { + // JWT 생성 + $now = time(); + $jwtHeader = $this->base64UrlEncode(json_encode(['alg' => 'RS256', 'typ' => 'JWT'])); + $jwtClaim = $this->base64UrlEncode(json_encode([ + 'iss' => $this->serviceAccount['client_email'], + 'scope' => 'https://www.googleapis.com/auth/devstorage.full_control', + 'aud' => 'https://oauth2.googleapis.com/token', + 'exp' => $now + 3600, + 'iat' => $now + ])); + + $privateKey = openssl_pkey_get_private($this->serviceAccount['private_key']); + if (!$privateKey) { + Log::error('GCS 토큰 실패: 개인 키 읽기 오류'); + return null; + } + + openssl_sign($jwtHeader . '.' . $jwtClaim, $signature, $privateKey, OPENSSL_ALGO_SHA256); + if (PHP_VERSION_ID < 80000) { + openssl_free_key($privateKey); + } + + $jwt = $jwtHeader . '.' . $jwtClaim . '.' . $this->base64UrlEncode($signature); + + // OAuth 토큰 요청 + $ch = curl_init('https://oauth2.googleapis.com/token'); + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query([ + 'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer', + 'assertion' => $jwt + ])); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/x-www-form-urlencoded']); + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($httpCode !== 200) { + Log::error('GCS 토큰 실패: HTTP ' . $httpCode); + return null; + } + + $data = json_decode($response, true); + return $data['access_token'] ?? null; + } + + /** + * Base64 URL 인코딩 + */ + private function base64UrlEncode(string $data): string + { + return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); + } + + /** + * 버킷 이름 반환 + */ + public function getBucketName(): ?string + { + return $this->bucketName; + } +} diff --git a/resources/views/sales/modals/voice-recorder.blade.php b/resources/views/sales/modals/voice-recorder.blade.php index f6c1041a..b90af34e 100644 --- a/resources/views/sales/modals/voice-recorder.blade.php +++ b/resources/views/sales/modals/voice-recorder.blade.php @@ -1,5 +1,254 @@ {{-- 음성 녹음 컴포넌트 --}} -
+

@@ -79,285 +328,7 @@ class="flex items-center gap-2 px-4 py-2 bg-gray-200 text-gray-700 rounded-lg ho {{-- 저장된 녹음 목록 안내 --}}

- 녹음 파일은 상담 기록에 자동으로 저장됩니다. + 녹음 파일은 상담 기록에 저장되며, 10MB 이상은 GCS에 백업됩니다.

- - diff --git a/routes/web.php b/routes/web.php index 2b66a2c1..2fb1ad02 100644 --- a/routes/web.php +++ b/routes/web.php @@ -808,6 +808,8 @@ Route::post('/upload-audio', [\App\Http\Controllers\Sales\ConsultationController::class, 'uploadAudio'])->name('upload-audio'); Route::post('/upload-file', [\App\Http\Controllers\Sales\ConsultationController::class, 'uploadFile'])->name('upload-file'); Route::delete('/file/{file}', [\App\Http\Controllers\Sales\ConsultationController::class, 'deleteFile'])->name('delete-file'); + Route::get('/download-audio/{consultation}', [\App\Http\Controllers\Sales\ConsultationController::class, 'downloadAudio'])->name('download-audio'); + Route::get('/download-file/{consultation}', [\App\Http\Controllers\Sales\ConsultationController::class, 'downloadFile'])->name('download-file'); }); // 매니저 지정 변경 From 50becbdd28c4796ac202f0408813f26c06ceb28a Mon Sep 17 00:00:00 2001 From: pro Date: Thu, 29 Jan 2026 09:22:12 +0900 Subject: [PATCH 067/129] =?UTF-8?q?feat:AI=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=EC=97=90=20GCS=20=EC=8A=A4=ED=86=A0?= =?UTF-8?q?=EB=A6=AC=EC=A7=80=20=EC=84=A4=EC=A0=95=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AI 설정과 스토리지 설정을 탭으로 구분 - GCS 버킷 이름, 서비스 계정 (JSON 직접입력/파일경로) 설정 가능 - GCS 연결 테스트 기능 추가 - GoogleCloudStorageService가 DB 설정 우선 사용 (fallback: 레거시 파일) - AiConfig 모델에 gcs provider 및 관련 메서드 추가 Co-Authored-By: Claude Opus 4.5 --- .../Controllers/System/AiConfigController.php | 195 ++++++++- app/Models/System/AiConfig.php | 55 +++ app/Services/GoogleCloudStorageService.php | 33 +- .../views/system/ai-config/index.blade.php | 382 +++++++++++++++++- routes/web.php | 1 + 5 files changed, 634 insertions(+), 32 deletions(-) diff --git a/app/Http/Controllers/System/AiConfigController.php b/app/Http/Controllers/System/AiConfigController.php index 4f7c052e..cc02fdd9 100644 --- a/app/Http/Controllers/System/AiConfigController.php +++ b/app/Http/Controllers/System/AiConfigController.php @@ -20,12 +20,25 @@ public function index(Request $request): View|Response return response('', 200)->header('HX-Redirect', route('system.ai-config.index')); } - $configs = AiConfig::orderBy('provider') + // AI 설정 (gemini, claude, openai) + $aiConfigs = AiConfig::whereIn('provider', AiConfig::AI_PROVIDERS) + ->orderBy('provider') ->orderByDesc('is_active') ->orderBy('name') ->get(); - return view('system.ai-config.index', compact('configs')); + // 스토리지 설정 (gcs) + $storageConfigs = AiConfig::whereIn('provider', AiConfig::STORAGE_PROVIDERS) + ->orderBy('provider') + ->orderByDesc('is_active') + ->orderBy('name') + ->get(); + + return view('system.ai-config.index', [ + 'configs' => $aiConfigs, + 'aiConfigs' => $aiConfigs, + 'storageConfigs' => $storageConfigs, + ]); } /** @@ -35,26 +48,40 @@ public function store(Request $request): JsonResponse { $validated = $request->validate([ 'name' => 'required|string|max:50', - 'provider' => 'required|string|in:gemini,claude,openai', + 'provider' => 'required|string|in:gemini,claude,openai,gcs', 'api_key' => 'nullable|string|max:255', - 'model' => 'required|string|max:100', + 'model' => 'nullable|string|max:100', 'base_url' => 'nullable|string|max:255', 'description' => 'nullable|string', 'is_active' => 'boolean', 'options' => 'nullable|array', - 'options.auth_type' => 'nullable|string|in:api_key,vertex_ai', + 'options.auth_type' => 'nullable|string|in:api_key,vertex_ai,service_account', 'options.project_id' => 'nullable|string|max:100', 'options.region' => 'nullable|string|max:50', 'options.service_account_path' => 'nullable|string|max:500', + 'options.bucket_name' => 'nullable|string|max:200', + 'options.service_account_json' => 'nullable|array', ]); - // Vertex AI가 아닌 경우 API 키 필수 - $authType = $validated['options']['auth_type'] ?? 'api_key'; - if ($authType !== 'vertex_ai' && empty($validated['api_key'])) { - return response()->json([ - 'ok' => false, - 'message' => 'API 키를 입력해주세요.', - ], 422); + // GCS의 경우 별도 검증 + if ($validated['provider'] === 'gcs') { + if (empty($validated['options']['bucket_name'])) { + return response()->json([ + 'ok' => false, + 'message' => '버킷 이름을 입력해주세요.', + ], 422); + } + $validated['model'] = '-'; // GCS는 모델 불필요 + $validated['api_key'] = 'gcs_service_account'; // DB NOT NULL 제약 + } else { + // AI 설정: Vertex AI가 아닌 경우 API 키 필수 + $authType = $validated['options']['auth_type'] ?? 'api_key'; + if ($authType !== 'vertex_ai' && empty($validated['api_key'])) { + return response()->json([ + 'ok' => false, + 'message' => 'API 키를 입력해주세요.', + ], 422); + } } // 활성화 시 동일 provider의 다른 설정 비활성화 @@ -81,26 +108,40 @@ public function update(Request $request, int $id): JsonResponse $validated = $request->validate([ 'name' => 'required|string|max:50', - 'provider' => 'required|string|in:gemini,claude,openai', + 'provider' => 'required|string|in:gemini,claude,openai,gcs', 'api_key' => 'nullable|string|max:255', - 'model' => 'required|string|max:100', + 'model' => 'nullable|string|max:100', 'base_url' => 'nullable|string|max:255', 'description' => 'nullable|string', 'is_active' => 'boolean', 'options' => 'nullable|array', - 'options.auth_type' => 'nullable|string|in:api_key,vertex_ai', + 'options.auth_type' => 'nullable|string|in:api_key,vertex_ai,service_account', 'options.project_id' => 'nullable|string|max:100', 'options.region' => 'nullable|string|max:50', 'options.service_account_path' => 'nullable|string|max:500', + 'options.bucket_name' => 'nullable|string|max:200', + 'options.service_account_json' => 'nullable|array', ]); - // Vertex AI가 아닌 경우 API 키 필수 - $authType = $validated['options']['auth_type'] ?? 'api_key'; - if ($authType !== 'vertex_ai' && empty($validated['api_key'])) { - return response()->json([ - 'ok' => false, - 'message' => 'API 키를 입력해주세요.', - ], 422); + // GCS의 경우 별도 검증 + if ($validated['provider'] === 'gcs') { + if (empty($validated['options']['bucket_name'])) { + return response()->json([ + 'ok' => false, + 'message' => '버킷 이름을 입력해주세요.', + ], 422); + } + $validated['model'] = '-'; + $validated['api_key'] = 'gcs_service_account'; + } else { + // AI 설정: Vertex AI가 아닌 경우 API 키 필수 + $authType = $validated['options']['auth_type'] ?? 'api_key'; + if ($authType !== 'vertex_ai' && empty($validated['api_key'])) { + return response()->json([ + 'ok' => false, + 'message' => 'API 키를 입력해주세요.', + ], 422); + } } // 활성화 시 동일 provider의 다른 설정 비활성화 @@ -225,4 +266,114 @@ private function testGemini(string $baseUrl, string $model, string $apiKey): arr 'error' => 'API 응답 오류: ' . $response->status(), ]; } + + /** + * GCS 연결 테스트 + */ + public function testGcs(Request $request): JsonResponse + { + $validated = $request->validate([ + 'bucket_name' => 'required|string', + 'service_account_path' => 'nullable|string', + 'service_account_json' => 'nullable|array', + ]); + + try { + $bucketName = $validated['bucket_name']; + $serviceAccount = null; + + // 서비스 계정 로드 (JSON 직접 입력 또는 파일 경로) + if (!empty($validated['service_account_json'])) { + $serviceAccount = $validated['service_account_json']; + } elseif (!empty($validated['service_account_path']) && file_exists($validated['service_account_path'])) { + $serviceAccount = json_decode(file_get_contents($validated['service_account_path']), true); + } + + if (!$serviceAccount) { + return response()->json([ + 'ok' => false, + 'error' => '서비스 계정 정보를 찾을 수 없습니다.', + ]); + } + + // OAuth 토큰 획득 + $accessToken = $this->getGcsAccessToken($serviceAccount); + if (!$accessToken) { + return response()->json([ + 'ok' => false, + 'error' => 'OAuth 토큰 획득 실패', + ]); + } + + // 버킷 존재 확인 + $response = \Illuminate\Support\Facades\Http::timeout(10) + ->withHeaders(['Authorization' => 'Bearer ' . $accessToken]) + ->get("https://storage.googleapis.com/storage/v1/b/{$bucketName}"); + + if ($response->successful()) { + return response()->json([ + 'ok' => true, + 'message' => "GCS 연결 성공! 버킷: {$bucketName}", + ]); + } + + return response()->json([ + 'ok' => false, + 'error' => '버킷 접근 실패: ' . $response->status(), + ]); + + } catch (\Exception $e) { + return response()->json([ + 'ok' => false, + 'error' => $e->getMessage(), + ]); + } + } + + /** + * GCS OAuth 토큰 획득 + */ + private function getGcsAccessToken(array $serviceAccount): ?string + { + $now = time(); + $jwtHeader = $this->base64UrlEncode(json_encode(['alg' => 'RS256', 'typ' => 'JWT'])); + $jwtClaim = $this->base64UrlEncode(json_encode([ + 'iss' => $serviceAccount['client_email'], + 'scope' => 'https://www.googleapis.com/auth/devstorage.full_control', + 'aud' => 'https://oauth2.googleapis.com/token', + 'exp' => $now + 3600, + 'iat' => $now + ])); + + $privateKey = openssl_pkey_get_private($serviceAccount['private_key']); + if (!$privateKey) { + return null; + } + + openssl_sign($jwtHeader . '.' . $jwtClaim, $signature, $privateKey, OPENSSL_ALGO_SHA256); + if (PHP_VERSION_ID < 80000) { + openssl_free_key($privateKey); + } + + $jwt = $jwtHeader . '.' . $jwtClaim . '.' . $this->base64UrlEncode($signature); + + $response = \Illuminate\Support\Facades\Http::asForm()->post('https://oauth2.googleapis.com/token', [ + 'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer', + 'assertion' => $jwt + ]); + + if ($response->successful()) { + return $response->json('access_token'); + } + + return null; + } + + /** + * Base64 URL 인코딩 + */ + private function base64UrlEncode(string $data): string + { + return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); + } } diff --git a/app/Models/System/AiConfig.php b/app/Models/System/AiConfig.php index 98d11863..36ff541f 100644 --- a/app/Models/System/AiConfig.php +++ b/app/Models/System/AiConfig.php @@ -51,6 +51,7 @@ class AiConfig extends Model 'gemini' => 'https://generativelanguage.googleapis.com/v1beta', 'claude' => 'https://api.anthropic.com/v1', 'openai' => 'https://api.openai.com/v1', + 'gcs' => 'https://storage.googleapis.com', ]; /** @@ -60,8 +61,19 @@ class AiConfig extends Model 'gemini' => 'gemini-2.0-flash', 'claude' => 'claude-sonnet-4-20250514', 'openai' => 'gpt-4o', + 'gcs' => '-', ]; + /** + * AI Provider 목록 (GCS 제외) + */ + public const AI_PROVIDERS = ['gemini', 'claude', 'openai']; + + /** + * 스토리지 Provider 목록 + */ + public const STORAGE_PROVIDERS = ['gcs']; + /** * 활성화된 Gemini 설정 조회 */ @@ -109,10 +121,53 @@ public function getProviderLabelAttribute(): string 'gemini' => 'Google Gemini', 'claude' => 'Anthropic Claude', 'openai' => 'OpenAI', + 'gcs' => 'Google Cloud Storage', default => $this->provider, }; } + /** + * 활성화된 GCS 설정 조회 + */ + public static function getActiveGcs(): ?self + { + return self::where('provider', 'gcs') + ->where('is_active', true) + ->first(); + } + + /** + * GCS 버킷 이름 + */ + public function getBucketName(): ?string + { + return $this->options['bucket_name'] ?? null; + } + + /** + * GCS 서비스 계정 JSON (직접 저장된 경우) + */ + public function getServiceAccountJson(): ?array + { + return $this->options['service_account_json'] ?? null; + } + + /** + * GCS 설정인지 확인 + */ + public function isGcs(): bool + { + return $this->provider === 'gcs'; + } + + /** + * AI 설정인지 확인 + */ + public function isAi(): bool + { + return in_array($this->provider, self::AI_PROVIDERS); + } + /** * 상태 라벨 */ diff --git a/app/Services/GoogleCloudStorageService.php b/app/Services/GoogleCloudStorageService.php index fa60cc9d..2b79e8cf 100644 --- a/app/Services/GoogleCloudStorageService.php +++ b/app/Services/GoogleCloudStorageService.php @@ -2,12 +2,13 @@ namespace App\Services; +use App\Models\System\AiConfig; use Illuminate\Support\Facades\Log; /** * Google Cloud Storage 업로드 서비스 * - * 레거시 PHP 코드와 동일한 방식으로 GCS에 파일을 업로드합니다. + * DB 설정(ai_configs 테이블)을 우선 사용하고, 없으면 레거시 파일 설정을 사용합니다. * JWT 인증 방식 사용. */ class GoogleCloudStorageService @@ -22,21 +23,47 @@ public function __construct() /** * GCS 설정 로드 + * + * 우선순위: + * 1. DB 설정 (ai_configs 테이블의 활성화된 gcs provider) + * 2. 레거시 파일 설정 (/sales/apikey/) */ private function loadConfig(): void { - // GCS 버킷 설정 + // 1. DB 설정 확인 + $dbConfig = AiConfig::getActiveGcs(); + + if ($dbConfig) { + $this->bucketName = $dbConfig->getBucketName(); + + // 서비스 계정: JSON 직접 입력 또는 파일 경로 + if ($dbConfig->getServiceAccountJson()) { + $this->serviceAccount = $dbConfig->getServiceAccountJson(); + } elseif ($dbConfig->getServiceAccountPath() && file_exists($dbConfig->getServiceAccountPath())) { + $this->serviceAccount = json_decode(file_get_contents($dbConfig->getServiceAccountPath()), true); + } + + if ($this->serviceAccount) { + Log::debug('GCS 설정 로드: DB (활성화된 설정: ' . $dbConfig->name . ')'); + return; + } + } + + // 2. 레거시 파일 설정 (fallback) $gcsConfigPath = base_path('../sales/apikey/gcs_config.txt'); if (file_exists($gcsConfigPath)) { $config = parse_ini_file($gcsConfigPath); $this->bucketName = $config['bucket_name'] ?? null; } - // 서비스 계정 로드 $serviceAccountPath = base_path('../sales/apikey/google_service_account.json'); if (file_exists($serviceAccountPath)) { $this->serviceAccount = json_decode(file_get_contents($serviceAccountPath), true); } + + if ($this->bucketName && $this->serviceAccount) { + Log::debug('GCS 설정 로드: 레거시 파일'); + } } /** diff --git a/resources/views/system/ai-config/index.blade.php b/resources/views/system/ai-config/index.blade.php index 791a021f..dfb99fa9 100644 --- a/resources/views/system/ai-config/index.blade.php +++ b/resources/views/system/ai-config/index.blade.php @@ -62,14 +62,14 @@ @endpush @section('content') -
+
-

AI 설정 관리

-

AI API 키 및 모델 설정을 관리합니다

+

AI 및 스토리지 설정

+

AI API 및 클라우드 스토리지 설정을 관리합니다

-
- -
+ +
+ + +
+ + +
@forelse($configs as $config)
@@ -132,7 +158,7 @@ @endforelse
- +

사용 안내

    @@ -142,6 +168,147 @@
  • 테스트 버튼으로 API 연결 상태를 확인할 수 있습니다.
+
+ + +
+ @forelse($storageConfigs as $config) +
+
+
+
+

{{ $config->name }}

+ + Google Cloud Storage + + + {{ $config->status_label }} + +
+
+

버킷: {{ $config->getBucketName() ?? '-' }}

+

서비스 계정: + @if($config->getServiceAccountPath()) + 파일 경로: {{ $config->getServiceAccountPath() }} + @elseif($config->getServiceAccountJson()) + JSON 직접 입력됨 + @else + 미설정 + @endif +

+ @if($config->description) +

설명: {{ $config->description }}

+ @endif +
+
+
+ + + + +
+
+
+ @empty +
+ + + +

등록된 GCS 설정이 없습니다.

+

'새 설정 추가' 버튼을 클릭하여 Google Cloud Storage를 등록하세요.

+
+ @endforelse + + +
+

Google Cloud Storage 사용 안내

+
    +
  • 음성 녹음 파일(10MB 이상)은 GCS에 자동 백업됩니다.
  • +
  • GCP 콘솔에서 서비스 계정을 생성하고 Storage 권한을 부여하세요.
  • +
  • 서비스 계정 키(JSON)를 직접 입력하거나, 파일 경로를 지정할 수 있습니다.
  • +
  • 버킷은 미리 GCP 콘솔에서 생성해 두어야 합니다.
  • +
+
+
+
+ + + @@ -539,6 +706,189 @@ function toggleAuthTypeUI(provider, authType) { } } + // === GCS 설정 관련 함수들 === + + // GCS 모달 열기 + window.openGcsModal = function(config) { + const modal = document.getElementById('gcs-modal'); + const title = document.getElementById('gcs-modal-title'); + + if (config) { + title.textContent = 'GCS 설정 수정'; + document.getElementById('gcs-config-id').value = config.id; + document.getElementById('gcs-name').value = config.name; + document.getElementById('gcs-description').value = config.description || ''; + document.getElementById('gcs-is-active').checked = config.is_active; + + const options = config.options || {}; + document.getElementById('gcs-bucket-name').value = options.bucket_name || ''; + document.getElementById('gcs-service-account-path').value = options.service_account_path || ''; + + if (options.service_account_json) { + document.getElementById('gcs-auth-type').value = 'json'; + document.getElementById('gcs-service-account-json').value = JSON.stringify(options.service_account_json, null, 2); + toggleGcsAuthType('json'); + } else { + document.getElementById('gcs-auth-type').value = 'path'; + toggleGcsAuthType('path'); + } + } else { + title.textContent = 'GCS 설정 추가'; + document.getElementById('gcs-form').reset(); + document.getElementById('gcs-config-id').value = ''; + document.getElementById('gcs-service-account-path').value = '/var/www/sales/apikey/google_service_account.json'; + toggleGcsAuthType('path'); + } + + modal.classList.remove('hidden'); + }; + + // GCS 모달 닫기 + window.closeGcsModal = function() { + document.getElementById('gcs-modal').classList.add('hidden'); + }; + + // GCS 인증 방식 전환 + function toggleGcsAuthType(type) { + const pathSection = document.getElementById('gcs-path-section'); + const jsonSection = document.getElementById('gcs-json-section'); + + if (type === 'json') { + pathSection.classList.add('hidden'); + jsonSection.classList.remove('hidden'); + } else { + pathSection.classList.remove('hidden'); + jsonSection.classList.add('hidden'); + } + } + + // GCS 수정 + window.editGcsConfig = function(btn) { + try { + const config = JSON.parse(btn.dataset.config); + window.openGcsModal(config); + } catch (e) { + console.error('Config parse error:', e); + showToast('설정 데이터를 불러올 수 없습니다.', 'error'); + } + }; + + // GCS 연결 테스트 (목록) + window.testGcsConnection = function(id) { + showToast('GCS 수정 화면에서 테스트해주세요.', 'warning'); + }; + + // GCS 연결 테스트 (모달) + window.testGcsConnectionFromModal = async function() { + const authType = document.getElementById('gcs-auth-type').value; + const data = { + bucket_name: document.getElementById('gcs-bucket-name').value, + }; + + if (authType === 'json') { + try { + const jsonText = document.getElementById('gcs-service-account-json').value; + data.service_account_json = JSON.parse(jsonText); + } catch (e) { + showToast('JSON 형식이 올바르지 않습니다.', 'error'); + return; + } + } else { + data.service_account_path = document.getElementById('gcs-service-account-path').value; + } + + if (!data.bucket_name) { + showToast('버킷 이름을 입력해주세요.', 'error'); + return; + } + + showToast('GCS 연결 테스트 중...', 'info'); + + try { + const response = await fetch('{{ route("system.ai-config.test-gcs") }}', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': csrfToken + }, + body: JSON.stringify(data) + }); + + const result = await response.json(); + + if (result.ok) { + showToast(result.message, 'success'); + } else { + showToast(result.error || '연결 테스트 실패', 'error'); + } + } catch (error) { + showToast('테스트 중 오류가 발생했습니다.', 'error'); + } + }; + + // GCS 폼 제출 + async function handleGcsFormSubmit(e) { + e.preventDefault(); + + const id = document.getElementById('gcs-config-id').value; + const authType = document.getElementById('gcs-auth-type').value; + + const data = { + provider: 'gcs', + name: document.getElementById('gcs-name').value, + description: document.getElementById('gcs-description').value || null, + is_active: document.getElementById('gcs-is-active').checked, + options: { + bucket_name: document.getElementById('gcs-bucket-name').value, + } + }; + + if (authType === 'json') { + try { + const jsonText = document.getElementById('gcs-service-account-json').value; + data.options.service_account_json = JSON.parse(jsonText); + } catch (e) { + showToast('JSON 형식이 올바르지 않습니다.', 'error'); + return; + } + } else { + data.options.service_account_path = document.getElementById('gcs-service-account-path').value; + } + + if (!data.options.bucket_name) { + showToast('버킷 이름을 입력해주세요.', 'error'); + return; + } + + try { + const url = id + ? `{{ url('system/ai-config') }}/${id}` + : '{{ route("system.ai-config.store") }}'; + const method = id ? 'PUT' : 'POST'; + + const response = await fetch(url, { + method: method, + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': csrfToken + }, + body: JSON.stringify(data) + }); + + const result = await response.json(); + + if (result.ok) { + showToast(result.message, 'success'); + window.closeGcsModal(); + location.reload(); + } else { + showToast(result.message || '저장 실패', 'error'); + } + } catch (error) { + showToast('저장 중 오류가 발생했습니다.', 'error'); + } + } + // DOM 로드 후 이벤트 리스너 등록 document.addEventListener('DOMContentLoaded', function() { // 페이지 로드 시 모달 강제 닫기 @@ -546,6 +896,10 @@ function toggleAuthTypeUI(provider, authType) { if (modal) { modal.classList.add('hidden'); } + const gcsModal = document.getElementById('gcs-modal'); + if (gcsModal) { + gcsModal.classList.add('hidden'); + } // Provider 변경 시 기본 모델 업데이트 및 UI 전환 const providerEl = document.getElementById('config-provider'); @@ -578,6 +932,20 @@ function toggleAuthTypeUI(provider, authType) { formEl.addEventListener('submit', handleFormSubmit); } + // GCS 폼 제출 + const gcsFormEl = document.getElementById('gcs-form'); + if (gcsFormEl) { + gcsFormEl.addEventListener('submit', handleGcsFormSubmit); + } + + // GCS 인증 방식 변경 + const gcsAuthTypeEl = document.getElementById('gcs-auth-type'); + if (gcsAuthTypeEl) { + gcsAuthTypeEl.addEventListener('change', function() { + toggleGcsAuthType(this.value); + }); + } + // 모달 외부 클릭 시 닫지 않음 (의도치 않은 닫힘 방지) // 닫기 버튼이나 취소 버튼으로만 닫을 수 있음 }); diff --git a/routes/web.php b/routes/web.php index 2fb1ad02..45f4211f 100644 --- a/routes/web.php +++ b/routes/web.php @@ -326,6 +326,7 @@ Route::delete('/{id}', [AiConfigController::class, 'destroy'])->name('destroy'); Route::post('/{id}/toggle', [AiConfigController::class, 'toggle'])->name('toggle'); Route::post('/test', [AiConfigController::class, 'test'])->name('test'); + Route::post('/test-gcs', [AiConfigController::class, 'testGcs'])->name('test-gcs'); }); // 명함 OCR API From 0d5045d535fd0a8db85706898e78cf800529af17 Mon Sep 17 00:00:00 2001 From: pro Date: Thu, 29 Jan 2026 09:29:50 +0900 Subject: [PATCH 068/129] =?UTF-8?q?fix:AI=20=EC=84=A4=EC=A0=95=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=ED=83=AD=20=EC=A0=84=ED=99=98=20UI=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - x-cloak 스타일 추가: Alpine.js 초기화 전 탭 콘텐츠 숨김 - 초기 로드 시 두 탭이 동시에 보이던 문제 해결 Co-Authored-By: Claude Opus 4.5 --- .../views/system/ai-config/index.blade.php | 242 +++++++++--------- 1 file changed, 125 insertions(+), 117 deletions(-) diff --git a/resources/views/system/ai-config/index.blade.php b/resources/views/system/ai-config/index.blade.php index dfb99fa9..8d23686d 100644 --- a/resources/views/system/ai-config/index.blade.php +++ b/resources/views/system/ai-config/index.blade.php @@ -4,6 +4,9 @@ @push('styles')