diff --git a/app/Http/Controllers/Sales/TenantProspectController.php b/app/Http/Controllers/Sales/TenantProspectController.php new file mode 100644 index 00000000..1c34d558 --- /dev/null +++ b/app/Http/Controllers/Sales/TenantProspectController.php @@ -0,0 +1,212 @@ +header('HX-Request')) { + return response('', 200)->header('HX-Redirect', route('sales.prospects.index')); + } + + $filters = [ + 'search' => $request->get('search'), + 'status' => $request->get('status'), + 'registered_by' => $request->get('registered_by'), + ]; + + $prospects = $this->service->getProspects($filters)->paginate(20); + $stats = $this->service->getStats(); + + return view('sales.prospects.index', compact('prospects', 'stats')); + } + + /** + * 등록 폼 + */ + public function create(Request $request): View|Response + { + // HTMX 요청이면 JavaScript 로드를 위해 전체 페이지로 리다이렉트 + if ($request->header('HX-Request')) { + return response('', 200)->header('HX-Redirect', route('sales.prospects.create')); + } + + return view('sales.prospects.create'); + } + + /** + * 등록 처리 + */ + public function store(Request $request) + { + $validated = $request->validate([ + 'business_number' => 'required|string|max:20', + 'company_name' => 'required|string|max:100', + 'ceo_name' => 'nullable|string|max:50', + 'contact_phone' => 'nullable|string|max:20', + 'contact_email' => 'nullable|email|max:100', + 'address' => 'nullable|string|max:500', + 'business_card' => 'nullable|image|max:5120', + 'memo' => 'nullable|string|max:1000', + ]); + + // 등록 가능 여부 확인 + $checkResult = $this->service->canRegister($validated['business_number']); + if (!$checkResult['can_register']) { + return redirect()->back() + ->withInput() + ->with('error', $checkResult['reason']); + } + + // 등록자는 현재 로그인 사용자 + $validated['registered_by'] = auth()->id(); + + $this->service->register( + $validated, + $request->file('business_card') + ); + + return redirect()->route('sales.prospects.index') + ->with('success', '명함이 등록되었습니다. 2개월간 영업권이 유효합니다.'); + } + + /** + * 상세 페이지 + */ + public function show(int $id): View + { + $prospect = TenantProspect::with(['registeredBy', 'tenant', 'convertedBy']) + ->findOrFail($id); + + return view('sales.prospects.show', compact('prospect')); + } + + /** + * 수정 폼 + */ + public function edit(int $id): View + { + $prospect = TenantProspect::findOrFail($id); + + // 이미 전환된 경우 수정 불가 + if ($prospect->isConverted()) { + return redirect()->route('sales.prospects.show', $id) + ->with('error', '이미 테넌트로 전환된 영업권은 수정할 수 없습니다.'); + } + + return view('sales.prospects.edit', compact('prospect')); + } + + /** + * 수정 처리 + */ + public function update(Request $request, int $id) + { + $prospect = TenantProspect::findOrFail($id); + + // 이미 전환된 경우 수정 불가 + if ($prospect->isConverted()) { + return redirect()->route('sales.prospects.show', $id) + ->with('error', '이미 테넌트로 전환된 영업권은 수정할 수 없습니다.'); + } + + $validated = $request->validate([ + 'company_name' => 'required|string|max:100', + 'ceo_name' => 'nullable|string|max:50', + 'contact_phone' => 'nullable|string|max:20', + 'contact_email' => 'nullable|email|max:100', + 'address' => 'nullable|string|max:500', + 'business_card' => 'nullable|image|max:5120', + 'memo' => 'nullable|string|max:1000', + ]); + + $this->service->update( + $prospect, + $validated, + $request->file('business_card') + ); + + return redirect()->route('sales.prospects.show', $id) + ->with('success', '영업권 정보가 수정되었습니다.'); + } + + /** + * 삭제 처리 + */ + public function destroy(int $id) + { + $prospect = TenantProspect::findOrFail($id); + + // 이미 전환된 경우 삭제 불가 + if ($prospect->isConverted()) { + return redirect()->route('sales.prospects.index') + ->with('error', '이미 테넌트로 전환된 영업권은 삭제할 수 없습니다.'); + } + + // 본인 또는 관리자만 삭제 가능 + if ($prospect->registered_by !== auth()->id()) { + // TODO: 관리자 권한 체크 추가 + } + + $prospect->delete(); + + return redirect()->route('sales.prospects.index') + ->with('success', '영업권이 삭제되었습니다.'); + } + + /** + * 테넌트 전환 + */ + public function convert(int $id) + { + $prospect = TenantProspect::findOrFail($id); + + // 이미 전환된 경우 + if ($prospect->isConverted()) { + return redirect()->route('sales.prospects.show', $id) + ->with('error', '이미 테넌트로 전환되었습니다.'); + } + + // 만료된 경우 + if ($prospect->isExpired()) { + return redirect()->route('sales.prospects.show', $id) + ->with('error', '만료된 영업권은 전환할 수 없습니다.'); + } + + $tenant = $this->service->convertToTenant($prospect, auth()->id()); + + return redirect()->route('sales.prospects.show', $id) + ->with('success', "테넌트로 전환되었습니다. (테넌트 ID: {$tenant->id})"); + } + + /** + * 사업자번호 중복 체크 (AJAX) + */ + public function checkBusinessNumber(Request $request) + { + $businessNumber = $request->get('business_number'); + $excludeId = $request->get('exclude_id'); + + $result = $this->service->canRegister($businessNumber, $excludeId); + + return response()->json($result); + } +} diff --git a/app/Models/Sales/TenantProspect.php b/app/Models/Sales/TenantProspect.php new file mode 100644 index 00000000..d6adb3b2 --- /dev/null +++ b/app/Models/Sales/TenantProspect.php @@ -0,0 +1,225 @@ + 'datetime', + 'expires_at' => 'datetime', + 'cooldown_ends_at' => 'datetime', + 'converted_at' => 'datetime', + ]; + + /** + * 등록한 영업파트너 + */ + public function registeredBy(): BelongsTo + { + return $this->belongsTo(User::class, 'registered_by'); + } + + /** + * 전환된 테넌트 + */ + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class, 'tenant_id'); + } + + /** + * 전환 처리자 + */ + public function convertedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'converted_by'); + } + + /** + * 영업권 유효 여부 + */ + public function isActive(): bool + { + return $this->status === self::STATUS_ACTIVE && now()->lt($this->expires_at); + } + + /** + * 영업권 만료 여부 + */ + public function isExpired(): bool + { + return $this->status === self::STATUS_EXPIRED || now()->gte($this->expires_at); + } + + /** + * 테넌트 전환 완료 여부 + */ + public function isConverted(): bool + { + return $this->status === self::STATUS_CONVERTED; + } + + /** + * 쿨다운 중 여부 + */ + public function isInCooldown(): bool + { + return $this->isExpired() && now()->lt($this->cooldown_ends_at); + } + + /** + * 재등록 가능 여부 + */ + public function canReRegister(): bool + { + return $this->isExpired() && now()->gte($this->cooldown_ends_at); + } + + /** + * 상태 라벨 + */ + public function getStatusLabelAttribute(): string + { + if ($this->isConverted()) { + return '계약완료'; + } + + if ($this->isActive()) { + return '영업중'; + } + + if ($this->isInCooldown()) { + return '쿨다운'; + } + + return '만료'; + } + + /** + * 상태 색상 (Tailwind CSS) + */ + public function getStatusColorAttribute(): string + { + if ($this->isConverted()) { + return 'bg-green-100 text-green-800'; + } + + if ($this->isActive()) { + return 'bg-blue-100 text-blue-800'; + } + + if ($this->isInCooldown()) { + return 'bg-yellow-100 text-yellow-800'; + } + + return 'bg-gray-100 text-gray-800'; + } + + /** + * 남은 일수 + */ + public function getRemainingDaysAttribute(): int + { + if (!$this->isActive()) { + return 0; + } + + return max(0, now()->diffInDays($this->expires_at, false)); + } + + /** + * 명함 이미지 URL + */ + public function getBusinessCardUrlAttribute(): ?string + { + if (!$this->business_card_path) { + return null; + } + + return Storage::disk('tenant')->url($this->business_card_path); + } + + /** + * 명함 이미지 존재 여부 + */ + public function hasBusinessCard(): bool + { + return $this->business_card_path && Storage::disk('tenant')->exists($this->business_card_path); + } + + /** + * 스코프: 유효한 영업권 + */ + public function scopeActive($query) + { + return $query->where('status', self::STATUS_ACTIVE) + ->where('expires_at', '>', now()); + } + + /** + * 스코프: 만료된 영업권 + */ + public function scopeExpired($query) + { + return $query->where(function ($q) { + $q->where('status', self::STATUS_EXPIRED) + ->orWhere('expires_at', '<=', now()); + }); + } + + /** + * 스코프: 전환 완료 + */ + public function scopeConverted($query) + { + return $query->where('status', self::STATUS_CONVERTED); + } + + /** + * 스코프: 특정 영업파트너의 영업권 + */ + public function scopeByPartner($query, int $userId) + { + return $query->where('registered_by', $userId); + } +} diff --git a/app/Models/Tenants/Tenant.php b/app/Models/Tenants/Tenant.php index 75cd556a..8fd6ea9e 100644 --- a/app/Models/Tenants/Tenant.php +++ b/app/Models/Tenants/Tenant.php @@ -40,6 +40,14 @@ class Tenant extends Model 'admin_memo', // 삭제 정보 'deleted_by', + // 영업파트너 영업권 관련 + 'registered_by', + 'business_card_path', + 'prospect_status', + 'prospect_registered_at', + 'prospect_expires_at', + 'cooldown_ends_at', + 'converted_at', ]; protected $casts = [ diff --git a/app/Services/Sales/TenantProspectService.php b/app/Services/Sales/TenantProspectService.php new file mode 100644 index 00000000..61b64922 --- /dev/null +++ b/app/Services/Sales/TenantProspectService.php @@ -0,0 +1,253 @@ +copy()->addMonths(TenantProspect::VALIDITY_MONTHS); + $cooldownEndsAt = $expiresAt->copy()->addMonths(TenantProspect::COOLDOWN_MONTHS); + + // 명함 이미지 저장 + $businessCardPath = null; + if ($businessCard) { + $businessCardPath = $this->uploadBusinessCard($businessCard, $data['registered_by']); + } + + return TenantProspect::create([ + 'business_number' => $data['business_number'], + 'company_name' => $data['company_name'], + 'ceo_name' => $data['ceo_name'] ?? null, + 'contact_phone' => $data['contact_phone'] ?? null, + 'contact_email' => $data['contact_email'] ?? null, + 'address' => $data['address'] ?? null, + 'registered_by' => $data['registered_by'], + 'business_card_path' => $businessCardPath, + 'status' => TenantProspect::STATUS_ACTIVE, + 'registered_at' => $now, + 'expires_at' => $expiresAt, + 'cooldown_ends_at' => $cooldownEndsAt, + 'memo' => $data['memo'] ?? null, + ]); + }); + } + + /** + * 영업권 정보 수정 + */ + public function update(TenantProspect $prospect, array $data, ?UploadedFile $businessCard = null): TenantProspect + { + return DB::transaction(function () use ($prospect, $data, $businessCard) { + $updateData = [ + 'company_name' => $data['company_name'], + 'ceo_name' => $data['ceo_name'] ?? null, + 'contact_phone' => $data['contact_phone'] ?? null, + 'contact_email' => $data['contact_email'] ?? null, + 'address' => $data['address'] ?? null, + 'memo' => $data['memo'] ?? null, + ]; + + // 명함 이미지 교체 + if ($businessCard) { + // 기존 이미지 삭제 + if ($prospect->business_card_path) { + Storage::disk('tenant')->delete($prospect->business_card_path); + } + $updateData['business_card_path'] = $this->uploadBusinessCard($businessCard, $prospect->registered_by); + } + + $prospect->update($updateData); + + return $prospect->fresh(); + }); + } + + /** + * 테넌트로 전환 + */ + public function convertToTenant(TenantProspect $prospect, int $convertedBy): Tenant + { + return DB::transaction(function () use ($prospect, $convertedBy) { + // 테넌트 생성 + $tenant = Tenant::create([ + 'company_name' => $prospect->company_name, + 'business_num' => $prospect->business_number, + 'ceo_name' => $prospect->ceo_name, + 'phone' => $prospect->contact_phone, + 'email' => $prospect->contact_email, + 'address' => $prospect->address, + 'tenant_st_code' => 'trial', + 'tenant_type' => 'customer', + 'created_by' => $convertedBy, + ]); + + // 영업권 상태 업데이트 + $prospect->update([ + 'status' => TenantProspect::STATUS_CONVERTED, + 'tenant_id' => $tenant->id, + 'converted_at' => now(), + 'converted_by' => $convertedBy, + ]); + + return $tenant; + }); + } + + /** + * 영업권 만료 처리 (배치용) + */ + public function expireOldProspects(): int + { + return TenantProspect::where('status', TenantProspect::STATUS_ACTIVE) + ->where('expires_at', '<=', now()) + ->update(['status' => TenantProspect::STATUS_EXPIRED]); + } + + /** + * 사업자번호로 등록 가능 여부 확인 + */ + public function canRegister(string $businessNumber, ?int $excludeId = null): array + { + $query = TenantProspect::where('business_number', $businessNumber); + + if ($excludeId) { + $query->where('id', '!=', $excludeId); + } + + // 이미 전환된 경우 + $converted = (clone $query)->where('status', TenantProspect::STATUS_CONVERTED)->first(); + if ($converted) { + return [ + 'can_register' => false, + 'reason' => '이미 테넌트로 전환된 회사입니다.', + 'prospect' => $converted, + ]; + } + + // 유효한 영업권이 있는 경우 + $active = (clone $query)->active()->first(); + if ($active) { + return [ + 'can_register' => false, + 'reason' => "이미 {$active->registeredBy->name}님이 영업권을 보유 중입니다. (만료: {$active->expires_at->format('Y-m-d')})", + 'prospect' => $active, + ]; + } + + // 쿨다운 중인 경우 + $inCooldown = (clone $query) + ->where('status', TenantProspect::STATUS_EXPIRED) + ->where('cooldown_ends_at', '>', now()) + ->first(); + + if ($inCooldown) { + return [ + 'can_register' => false, + 'reason' => "쿨다운 기간 중입니다. (등록 가능: {$inCooldown->cooldown_ends_at->format('Y-m-d')})", + 'prospect' => $inCooldown, + ]; + } + + return [ + 'can_register' => true, + 'reason' => null, + 'prospect' => null, + ]; + } + + /** + * 목록 조회 + */ + public function getProspects(array $filters = []) + { + $query = TenantProspect::with(['registeredBy', 'tenant']); + + // 검색 + if (!empty($filters['search'])) { + $search = $filters['search']; + $query->where(function ($q) use ($search) { + $q->where('company_name', 'like', "%{$search}%") + ->orWhere('business_number', 'like', "%{$search}%") + ->orWhere('ceo_name', 'like', "%{$search}%") + ->orWhere('contact_phone', 'like', "%{$search}%"); + }); + } + + // 상태 필터 + if (!empty($filters['status'])) { + if ($filters['status'] === 'active') { + $query->active(); + } elseif ($filters['status'] === 'expired') { + $query->where('status', TenantProspect::STATUS_EXPIRED); + } elseif ($filters['status'] === 'converted') { + $query->converted(); + } + } + + // 특정 영업파트너 + if (!empty($filters['registered_by'])) { + $query->byPartner($filters['registered_by']); + } + + return $query->orderBy('created_at', 'desc'); + } + + /** + * 통계 조회 + */ + public function getStats(?int $partnerId = null): array + { + $baseQuery = TenantProspect::query(); + + if ($partnerId) { + $baseQuery->byPartner($partnerId); + } + + return [ + 'total' => (clone $baseQuery)->count(), + 'active' => (clone $baseQuery)->active()->count(), + 'expired' => (clone $baseQuery)->where('status', TenantProspect::STATUS_EXPIRED)->count(), + 'converted' => (clone $baseQuery)->converted()->count(), + ]; + } + + /** + * 명함 이미지 업로드 + */ + private function uploadBusinessCard(UploadedFile $file, int $userId): string + { + $storedName = Str::uuid() . '.' . $file->getClientOriginalExtension(); + $filePath = "prospects/{$userId}/{$storedName}"; + + Storage::disk('tenant')->put($filePath, file_get_contents($file)); + + return $filePath; + } + + /** + * 명함 이미지 삭제 + */ + public function deleteBusinessCard(TenantProspect $prospect): bool + { + if ($prospect->business_card_path && Storage::disk('tenant')->exists($prospect->business_card_path)) { + Storage::disk('tenant')->delete($prospect->business_card_path); + $prospect->update(['business_card_path' => null]); + return true; + } + + return false; + } +} diff --git a/resources/views/sales/prospects/create.blade.php b/resources/views/sales/prospects/create.blade.php index 0c2716df..fb0ca70d 100644 --- a/resources/views/sales/prospects/create.blade.php +++ b/resources/views/sales/prospects/create.blade.php @@ -1,6 +1,6 @@ @extends('layouts.app') -@section('title', '가망고객 등록') +@section('title', '명함 등록') @section('content')
명함 등록 시 2개월간 영업권이 부여됩니다