refactor:영업관리 데이터를 DB 테이블로 변경

- 모델 추가: SalesPartner, SalesTenantManagement, SalesScenarioChecklist, SalesConsultation
- 모델 위치 이동: app/Models/ → app/Models/Sales/
- 컨트롤러 수정: 캐시 대신 DB 모델 사용
- 뷰 수정: Eloquent 모델 속성 사용

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
pro
2026-01-29 06:42:32 +09:00
parent 2f381b2285
commit 329c58e63b
11 changed files with 889 additions and 319 deletions

View File

@@ -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,
]);
}
}

View File

@@ -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([

View File

@@ -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]);
}
}

View File

@@ -0,0 +1,248 @@
<?php
namespace App\Models\Sales;
use App\Models\Tenants\Tenant;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\Storage;
/**
* 영업 상담 기록 모델 (텍스트, 음성, 첨부파일)
*
* @property int $id
* @property int $tenant_id
* @property string $scenario_type (sales/manager)
* @property int|null $step_id
* @property string $consultation_type (text/audio/file)
* @property string|null $content
* @property string|null $file_path
* @property string|null $file_name
* @property int|null $file_size
* @property string|null $file_type
* @property string|null $transcript
* @property int|null $duration
* @property int $created_by
*/
class SalesConsultation extends Model
{
use SoftDeletes;
protected $table = 'sales_consultations';
protected $fillable = [
'tenant_id',
'scenario_type',
'step_id',
'consultation_type',
'content',
'file_path',
'file_name',
'file_size',
'file_type',
'transcript',
'duration',
'created_by',
];
protected $casts = [
'step_id' => '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);
}
}

View File

@@ -0,0 +1,116 @@
<?php
namespace App\Models\Sales;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* 영업 파트너(영업 담당자) 모델
*
* @property int $id
* @property int $user_id
* @property string $partner_code
* @property string $partner_type
* @property float $commission_rate
* @property float $manager_commission_rate
* @property string|null $bank_name
* @property string|null $account_number
* @property string|null $account_holder
* @property string $status
* @property \Carbon\Carbon|null $approved_at
* @property int|null $approved_by
* @property int $total_contracts
* @property float $total_commission
* @property string|null $notes
*/
class SalesPartner extends Model
{
use SoftDeletes;
protected $table = 'sales_partners';
protected $fillable = [
'user_id',
'partner_code',
'partner_type',
'commission_rate',
'manager_commission_rate',
'bank_name',
'account_number',
'account_holder',
'status',
'approved_at',
'approved_by',
'total_contracts',
'total_commission',
'notes',
];
protected $casts = [
'commission_rate' => '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');
}
}

View File

@@ -0,0 +1,168 @@
<?php
namespace App\Models\Sales;
use App\Models\Tenants\Tenant;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* 영업 시나리오 체크리스트 모델
*
* @property int $id
* @property int $tenant_id
* @property string $scenario_type (sales/manager)
* @property int $step_id
* @property string|null $checkpoint_id
* @property int|null $checkpoint_index
* @property bool $is_checked
* @property \Carbon\Carbon|null $checked_at
* @property int|null $checked_by
* @property string|null $memo
*/
class SalesScenarioChecklist extends Model
{
protected $table = 'sales_scenario_checklists';
protected $fillable = [
'tenant_id',
'scenario_type',
'step_id',
'checkpoint_id',
'checkpoint_index',
'is_checked',
'checked_at',
'checked_by',
'memo',
'user_id', // 하위 호환성
];
protected $casts = [
'step_id' => '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);
}
}

View File

@@ -0,0 +1,203 @@
<?php
namespace App\Models\Sales;
use App\Models\Tenants\Tenant;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* 테넌트별 영업 관리 모델 (tenants 외래키 연결)
*
* @property int $id
* @property int $tenant_id
* @property int|null $sales_partner_id
* @property int|null $manager_user_id
* @property int $sales_scenario_step
* @property int $manager_scenario_step
* @property string $status
* @property \Carbon\Carbon|null $first_contact_at
* @property \Carbon\Carbon|null $contracted_at
* @property \Carbon\Carbon|null $onboarding_completed_at
* @property float|null $membership_fee
* @property \Carbon\Carbon|null $membership_paid_at
* @property string|null $membership_status
* @property float|null $sales_commission
* @property float|null $manager_commission
* @property \Carbon\Carbon|null $commission_paid_at
* @property string|null $commission_status
* @property int $sales_progress
* @property int $manager_progress
* @property string|null $notes
*/
class SalesTenantManagement extends Model
{
use SoftDeletes;
protected $table = 'sales_tenant_managements';
protected $fillable = [
'tenant_id',
'sales_partner_id',
'manager_user_id',
'sales_scenario_step',
'manager_scenario_step',
'status',
'first_contact_at',
'contracted_at',
'onboarding_completed_at',
'membership_fee',
'membership_paid_at',
'membership_status',
'sales_commission',
'manager_commission',
'commission_paid_at',
'commission_status',
'sales_progress',
'manager_progress',
'notes',
];
protected $casts = [
'sales_scenario_step' => '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,
]);
}
}

View File

@@ -1,37 +0,0 @@
<?php
namespace App\Models;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* 영업 시나리오 체크리스트 모델
*/
class SalesScenarioChecklist extends Model
{
use BelongsToTenant;
protected $fillable = [
'tenant_id',
'user_id',
'step_id',
'checkpoint_index',
'is_checked',
];
protected $casts = [
'step_id' => 'integer',
'checkpoint_index' => 'integer',
'is_checked' => 'boolean',
];
/**
* 사용자 관계
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@@ -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
<div x-data="managerDropdown({{ $tenant->id }}, {{ json_encode($assignedManager) }})" class="relative">
<div x-data="managerDropdown({{ $tenant->id }}, {{ json_encode($assignedManager ? ['id' => $assignedManager->id, 'name' => $assignedManager->name, 'is_self' => $isSelf] : null) }})" class="relative">
{{-- 드롭다운 트리거 --}}
<button
@click="toggle()"

View File

@@ -30,9 +30,9 @@ class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white b
{{-- 상담 기록 목록 --}}
<div class="space-y-3">
<h4 class="text-sm font-semibold text-gray-700">상담 기록 ({{ count($consultations) }})</h4>
<h4 class="text-sm font-semibold text-gray-700">상담 기록 ({{ $consultations->count() }})</h4>
@if(empty($consultations))
@if($consultations->isEmpty())
<div class="text-center py-8 text-gray-500">
<svg class="w-12 h-12 mx-auto mb-2 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
@@ -43,10 +43,10 @@ class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white b
<div class="space-y-3 max-h-80 overflow-y-auto">
@foreach($consultations as $consultation)
<div class="group relative bg-gray-50 rounded-lg p-4 hover:bg-gray-100 transition-colors"
data-consultation-id="{{ $consultation['id'] }}">
data-consultation-id="{{ $consultation->id }}">
{{-- 삭제 버튼 --}}
<button
@click="deleteConsultation('{{ $consultation['id'] }}')"
@click="deleteConsultation({{ $consultation->id }})"
class="absolute top-2 right-2 p-1 text-gray-400 hover:text-red-600 opacity-0 group-hover:opacity-100 transition-opacity">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
@@ -57,14 +57,14 @@ class="absolute top-2 right-2 p-1 text-gray-400 hover:text-red-600 opacity-0 gro
<div class="flex items-start gap-3">
{{-- 타입 아이콘 --}}
<div class="flex-shrink-0 p-2 rounded-lg
@if($consultation['type'] === 'text') bg-blue-100 text-blue-600
@elseif($consultation['type'] === 'audio') bg-purple-100 text-purple-600
@if($consultation->consultation_type === 'text') bg-blue-100 text-blue-600
@elseif($consultation->consultation_type === 'audio') bg-purple-100 text-purple-600
@else bg-green-100 text-green-600 @endif">
@if($consultation['type'] === 'text')
@if($consultation->consultation_type === 'text')
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
</svg>
@elseif($consultation['type'] === 'audio')
@elseif($consultation->consultation_type === 'audio')
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z" />
</svg>
@@ -76,36 +76,36 @@ class="absolute top-2 right-2 p-1 text-gray-400 hover:text-red-600 opacity-0 gro
</div>
<div class="flex-1 min-w-0">
@if($consultation['type'] === 'text')
<p class="text-sm text-gray-700 whitespace-pre-wrap">{{ $consultation['content'] }}</p>
@elseif($consultation['type'] === 'audio')
@if($consultation->consultation_type === 'text')
<p class="text-sm text-gray-700 whitespace-pre-wrap">{{ $consultation->content }}</p>
@elseif($consultation->consultation_type === 'audio')
<div class="space-y-2">
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-gray-900">음성 녹음</span>
@if(isset($consultation['duration']))
@if($consultation->duration)
<span class="text-xs text-gray-500">
{{ floor($consultation['duration'] / 60) }}:{{ str_pad($consultation['duration'] % 60, 2, '0', STR_PAD_LEFT) }}
{{ $consultation->formatted_duration }}
</span>
@endif
</div>
@if(isset($consultation['transcript']) && $consultation['transcript'])
<p class="text-sm text-gray-600 italic">"{{ $consultation['transcript'] }}"</p>
@if($consultation->transcript)
<p class="text-sm text-gray-600 italic">"{{ $consultation->transcript }}"</p>
@endif
</div>
@else
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-gray-900">{{ $consultation['file_name'] }}</span>
<span class="text-sm font-medium text-gray-900">{{ $consultation->file_name }}</span>
<span class="text-xs text-gray-500">
{{ number_format(($consultation['file_size'] ?? 0) / 1024, 1) }} KB
{{ $consultation->formatted_file_size }}
</span>
</div>
@endif
{{-- 메타 정보 --}}
<div class="mt-2 flex items-center gap-2 text-xs text-gray-500">
<span>{{ $consultation['created_by_name'] }}</span>
<span>{{ $consultation->creator?->name ?? '알 수 없음' }}</span>
<span>|</span>
<span>{{ \Carbon\Carbon::parse($consultation['created_at'])->format('Y-m-d H:i') }}</span>
<span>{{ $consultation->created_at->format('Y-m-d H:i') }}</span>
</div>
</div>
</div>
@@ -175,10 +175,6 @@ function consultationLog() {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
'Accept': 'application/json',
},
body: JSON.stringify({
tenant_id: this.tenantId,
scenario_type: this.scenarioType,
}),
});
const result = await response.json();

View File

@@ -1,7 +1,10 @@
{{-- 시나리오 단계별 체크리스트 --}}
@php
use App\Models\Sales\SalesScenarioChecklist;
$step = $step ?? collect($steps)->firstWhere('id', $currentStep ?? 1);
$checkedItems = $progress[$step['id']] ?? [];
// DB에서 체크된 항목 조회
$checklist = SalesScenarioChecklist::getChecklist($tenant->id, $scenarioType);
@endphp
<div class="space-y-6">
@@ -42,7 +45,7 @@
@foreach($step['checkpoints'] as $checkpoint)
@php
$checkKey = "{$step['id']}_{$checkpoint['id']}";
$isChecked = isset($progress[$checkKey]);
$isChecked = isset($checklist[$checkKey]);
@endphp
<div x-data="{ expanded: false, checked: {{ $isChecked ? 'true' : 'false' }} }"
class="bg-white border rounded-xl overflow-hidden transition-all duration-200"