- 승인 전(pending): 라디오 버튼으로 개인/단체 변경 가능 - 승인 후(approved): 읽기 전용 뱃지로 표시 - 유형 변경 시 수당률 자동 설정 (단체 30%, 개인 초기화) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
651 lines
21 KiB
PHP
651 lines
21 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Sales;
|
|
|
|
use App\Models\Department;
|
|
use App\Models\DepartmentUser;
|
|
use App\Models\Role;
|
|
use App\Models\Sales\SalesManagerDocument;
|
|
use App\Models\Sales\SalesPartner;
|
|
use App\Models\User;
|
|
use App\Models\UserRole;
|
|
use Illuminate\Http\UploadedFile;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Hash;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Illuminate\Support\Str;
|
|
|
|
class SalesManagerService
|
|
{
|
|
/**
|
|
* 영업파트너 역할 이름 목록
|
|
*/
|
|
public const SALES_ROLES = ['sales', 'manager'];
|
|
|
|
/**
|
|
* 영업파트너 생성
|
|
*/
|
|
public function createSalesPartner(array $data, array $documents = []): User
|
|
{
|
|
return DB::transaction(function () use ($data, $documents) {
|
|
$tenantId = session('selected_tenant_id', 1);
|
|
|
|
// 1. 사용자 생성
|
|
$user = User::create([
|
|
'user_id' => $data['user_id'] ?? null,
|
|
'name' => $data['name'],
|
|
'email' => $data['email'],
|
|
'phone' => $data['phone'] ?? null,
|
|
'password' => Hash::make($data['password']),
|
|
'is_active' => false, // 승인 전까지 비활성
|
|
'parent_id' => $data['parent_id'] ?? null, // 추천인(유치자)
|
|
'approval_status' => 'pending', // 승인 대기 상태
|
|
'must_change_password' => true,
|
|
]);
|
|
|
|
// 2. 테넌트 연결
|
|
$user->tenants()->attach($tenantId, [
|
|
'is_active' => true,
|
|
'is_default' => true,
|
|
'joined_at' => now(),
|
|
]);
|
|
|
|
// 3. 역할 할당
|
|
if (!empty($data['role_ids'])) {
|
|
$this->syncRoles($user, $tenantId, $data['role_ids']);
|
|
}
|
|
|
|
// 4. 영업팀 부서 자동 할당
|
|
$this->assignSalesDepartment($user, $tenantId);
|
|
|
|
// 4-1. 사업자 정보 저장 (선택)
|
|
if (!empty($data['company_name']) || !empty($data['biz_no']) || !empty($data['address'])) {
|
|
$partnerType = $data['partner_type'] ?? 'individual';
|
|
$spData = [
|
|
'partner_code' => SalesPartner::generatePartnerCode(),
|
|
'partner_type' => $partnerType,
|
|
'status' => 'active',
|
|
'company_name' => $data['company_name'] ?? null,
|
|
'biz_no' => $data['biz_no'] ?? null,
|
|
'address' => $data['address'] ?? null,
|
|
'referrer_partner_id' => $data['referrer_partner_id'] ?? null,
|
|
];
|
|
|
|
// 단체(corporate)이면 수당률 설정
|
|
if ($partnerType === 'corporate') {
|
|
$spData['commission_rate'] = 30.00;
|
|
$spData['manager_commission_rate'] = 0;
|
|
}
|
|
|
|
SalesPartner::updateOrCreate(
|
|
['user_id' => $user->id],
|
|
$spData
|
|
);
|
|
}
|
|
|
|
// 5. 첨부 서류 저장
|
|
if (!empty($documents)) {
|
|
$this->uploadDocuments($user, $tenantId, $documents);
|
|
}
|
|
|
|
return $user;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 영업파트너 수정
|
|
*/
|
|
public function updateSalesPartner(User $user, array $data, array $documents = []): User
|
|
{
|
|
return DB::transaction(function () use ($user, $data, $documents) {
|
|
$tenantId = session('selected_tenant_id', 1);
|
|
|
|
// 1. 기본 정보 업데이트 (parent_id는 변경 불가)
|
|
$updateData = [
|
|
'name' => $data['name'],
|
|
'email' => $data['email'],
|
|
'phone' => $data['phone'] ?? null,
|
|
];
|
|
|
|
// 비밀번호 변경 시에만 업데이트
|
|
if (!empty($data['password'])) {
|
|
$updateData['password'] = Hash::make($data['password']);
|
|
}
|
|
|
|
$user->update($updateData);
|
|
|
|
// 2. 역할 동기화
|
|
if (isset($data['role_ids'])) {
|
|
$this->syncRoles($user, $tenantId, $data['role_ids']);
|
|
}
|
|
|
|
// 2-1. 사업자 정보 업데이트
|
|
$hasBizInfo = !empty($data['company_name']) || !empty($data['biz_no']) || !empty($data['address']);
|
|
$existingSp = SalesPartner::where('user_id', $user->id)->first();
|
|
|
|
$hasPartnerType = !empty($data['partner_type']);
|
|
if ($hasBizInfo || $existingSp || $hasPartnerType) {
|
|
$sp = $existingSp ?? new SalesPartner(['user_id' => $user->id]);
|
|
if (!$sp->exists) {
|
|
$sp->partner_code = SalesPartner::generatePartnerCode();
|
|
$sp->partner_type = $data['partner_type'] ?? 'individual';
|
|
$sp->status = 'active';
|
|
}
|
|
// 승인 전에만 파트너 유형 변경 허용
|
|
if ($hasPartnerType && $user->approval_status === 'pending') {
|
|
$newType = $data['partner_type'];
|
|
$sp->partner_type = $newType;
|
|
// 단체 → 수당률 설정, 개인 → 수당률 초기화
|
|
if ($newType === 'corporate') {
|
|
$sp->commission_rate = 30.00;
|
|
$sp->manager_commission_rate = 0;
|
|
} else {
|
|
$sp->commission_rate = $sp->getOriginal('commission_rate') ?? null;
|
|
$sp->manager_commission_rate = $sp->getOriginal('manager_commission_rate') ?? null;
|
|
}
|
|
}
|
|
$sp->company_name = $data['company_name'] ?? null;
|
|
$sp->biz_no = $data['biz_no'] ?? null;
|
|
$sp->address = $data['address'] ?? null;
|
|
$sp->save();
|
|
}
|
|
|
|
// 3. 새 첨부 서류 저장
|
|
if (!empty($documents)) {
|
|
$this->uploadDocuments($user, $tenantId, $documents);
|
|
}
|
|
|
|
return $user->fresh();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 영업파트너 승인
|
|
*/
|
|
public function approve(User $user, int $approverId): User
|
|
{
|
|
$user->update([
|
|
'approval_status' => 'approved',
|
|
'approved_by' => $approverId,
|
|
'approved_at' => now(),
|
|
'is_active' => true,
|
|
'rejection_reason' => null,
|
|
]);
|
|
|
|
return $user;
|
|
}
|
|
|
|
/**
|
|
* 영업파트너 반려
|
|
*/
|
|
public function reject(User $user, int $approverId, string $reason): User
|
|
{
|
|
$user->update([
|
|
'approval_status' => 'rejected',
|
|
'approved_by' => $approverId,
|
|
'approved_at' => now(),
|
|
'rejection_reason' => $reason,
|
|
'is_active' => false,
|
|
]);
|
|
|
|
return $user;
|
|
}
|
|
|
|
/**
|
|
* 역할 위임
|
|
*
|
|
* @param User $fromUser 역할을 넘기는 파트너
|
|
* @param User $toUser 역할을 받는 파트너
|
|
* @param string $roleName 위임할 역할 (manager, recruiter 등)
|
|
*/
|
|
public function delegateRole(User $fromUser, User $toUser, string $roleName): bool
|
|
{
|
|
$tenantId = session('selected_tenant_id', 1);
|
|
|
|
return DB::transaction(function () use ($fromUser, $toUser, $roleName, $tenantId) {
|
|
// 역할 조회
|
|
$role = Role::where('tenant_id', $tenantId)
|
|
->where('name', $roleName)
|
|
->first();
|
|
|
|
if (!$role) {
|
|
throw new \InvalidArgumentException("역할을 찾을 수 없습니다: {$roleName}");
|
|
}
|
|
|
|
// fromUser가 해당 역할을 가지고 있는지 확인
|
|
$hasRole = UserRole::where('user_id', $fromUser->id)
|
|
->where('tenant_id', $tenantId)
|
|
->where('role_id', $role->id)
|
|
->exists();
|
|
|
|
if (!$hasRole) {
|
|
throw new \InvalidArgumentException("{$fromUser->name}님이 {$roleName} 역할을 보유하고 있지 않습니다.");
|
|
}
|
|
|
|
// fromUser에서 역할 제거
|
|
UserRole::where('user_id', $fromUser->id)
|
|
->where('tenant_id', $tenantId)
|
|
->where('role_id', $role->id)
|
|
->delete();
|
|
|
|
// toUser에게 역할 추가 (이미 있으면 무시)
|
|
UserRole::firstOrCreate([
|
|
'user_id' => $toUser->id,
|
|
'tenant_id' => $tenantId,
|
|
'role_id' => $role->id,
|
|
], [
|
|
'assigned_at' => now(),
|
|
]);
|
|
|
|
return true;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 역할 부여
|
|
*/
|
|
public function assignRole(User $user, string $roleName): bool
|
|
{
|
|
$tenantId = session('selected_tenant_id', 1);
|
|
|
|
$role = Role::where('tenant_id', $tenantId)
|
|
->where('name', $roleName)
|
|
->first();
|
|
|
|
if (!$role) {
|
|
return false;
|
|
}
|
|
|
|
UserRole::firstOrCreate([
|
|
'user_id' => $user->id,
|
|
'tenant_id' => $tenantId,
|
|
'role_id' => $role->id,
|
|
], [
|
|
'assigned_at' => now(),
|
|
]);
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* 역할 제거
|
|
*/
|
|
public function removeRole(User $user, string $roleName): bool
|
|
{
|
|
$tenantId = session('selected_tenant_id', 1);
|
|
|
|
$role = Role::where('tenant_id', $tenantId)
|
|
->where('name', $roleName)
|
|
->first();
|
|
|
|
if (!$role) {
|
|
return false;
|
|
}
|
|
|
|
UserRole::where('user_id', $user->id)
|
|
->where('tenant_id', $tenantId)
|
|
->where('role_id', $role->id)
|
|
->delete();
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* 영업팀 부서 자동 할당
|
|
*/
|
|
private function assignSalesDepartment(User $user, int $tenantId): void
|
|
{
|
|
// "영업팀" 부서를 찾거나 생성
|
|
$salesDepartment = Department::firstOrCreate(
|
|
[
|
|
'tenant_id' => $tenantId,
|
|
'name' => '영업팀',
|
|
],
|
|
[
|
|
'code' => 'SALES',
|
|
'description' => '영업파트너 부서',
|
|
'is_active' => true,
|
|
'sort_order' => 100,
|
|
'created_by' => auth()->id(),
|
|
]
|
|
);
|
|
|
|
// 사용자-부서 연결 (이미 있으면 무시)
|
|
DepartmentUser::firstOrCreate(
|
|
[
|
|
'tenant_id' => $tenantId,
|
|
'department_id' => $salesDepartment->id,
|
|
'user_id' => $user->id,
|
|
],
|
|
[
|
|
'is_primary' => true,
|
|
'joined_at' => now(),
|
|
'created_by' => auth()->id(),
|
|
]
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 역할 동기화
|
|
*/
|
|
public function syncRoles(User $user, int $tenantId, array $roleIds): void
|
|
{
|
|
// 영업 관련 역할만 처리 (다른 역할은 유지)
|
|
$salesRoleIds = Role::where('tenant_id', $tenantId)
|
|
->whereIn('name', self::SALES_ROLES)
|
|
->pluck('id')
|
|
->toArray();
|
|
|
|
// 제거할 역할 soft delete (새 역할 목록에 없는 것들)
|
|
$roleIdsToRemove = array_diff($salesRoleIds, $roleIds);
|
|
if (!empty($roleIdsToRemove)) {
|
|
UserRole::where('user_id', $user->id)
|
|
->where('tenant_id', $tenantId)
|
|
->whereIn('role_id', $roleIdsToRemove)
|
|
->delete();
|
|
}
|
|
|
|
// 새 역할 추가 (이미 있으면 복원, 없으면 생성)
|
|
foreach ($roleIds as $roleId) {
|
|
// soft delete된 레코드 포함하여 검색
|
|
$existingRole = UserRole::withTrashed()
|
|
->where('user_id', $user->id)
|
|
->where('tenant_id', $tenantId)
|
|
->where('role_id', $roleId)
|
|
->first();
|
|
|
|
if ($existingRole) {
|
|
// 이미 존재하면 복원 (soft delete된 경우)
|
|
if ($existingRole->trashed()) {
|
|
$existingRole->restore();
|
|
}
|
|
} else {
|
|
// 새로 생성
|
|
UserRole::create([
|
|
'user_id' => $user->id,
|
|
'tenant_id' => $tenantId,
|
|
'role_id' => $roleId,
|
|
'assigned_at' => now(),
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 서류 업로드
|
|
*/
|
|
public function uploadDocuments(User $user, int $tenantId, array $documents): array
|
|
{
|
|
$uploaded = [];
|
|
|
|
foreach ($documents as $doc) {
|
|
if (!isset($doc['file']) || !$doc['file'] instanceof UploadedFile) {
|
|
continue;
|
|
}
|
|
|
|
$file = $doc['file'];
|
|
$documentType = $doc['document_type'] ?? 'other';
|
|
$description = $doc['description'] ?? null;
|
|
|
|
// 파일 저장
|
|
$storedName = Str::uuid() . '.' . $file->getClientOriginalExtension();
|
|
$filePath = "sales-partners/{$user->id}/{$storedName}";
|
|
|
|
Storage::disk('tenant')->put($filePath, file_get_contents($file));
|
|
|
|
// DB 저장
|
|
$document = SalesManagerDocument::create([
|
|
'tenant_id' => $tenantId,
|
|
'user_id' => $user->id,
|
|
'file_path' => $filePath,
|
|
'original_name' => $file->getClientOriginalName(),
|
|
'stored_name' => $storedName,
|
|
'mime_type' => $file->getMimeType(),
|
|
'file_size' => $file->getSize(),
|
|
'document_type' => $documentType,
|
|
'description' => $description,
|
|
'uploaded_by' => auth()->id(),
|
|
]);
|
|
|
|
$uploaded[] = $document;
|
|
}
|
|
|
|
return $uploaded;
|
|
}
|
|
|
|
/**
|
|
* 서류 삭제
|
|
*/
|
|
public function deleteDocument(SalesManagerDocument $document): bool
|
|
{
|
|
// 물리 파일 삭제
|
|
if ($document->existsInStorage()) {
|
|
Storage::disk('tenant')->delete($document->file_path);
|
|
}
|
|
|
|
// DB 삭제
|
|
$document->deleted_by = auth()->id();
|
|
$document->save();
|
|
|
|
return $document->delete();
|
|
}
|
|
|
|
/**
|
|
* 영업파트너 목록 조회
|
|
*/
|
|
public function getSalesPartners(array $filters = [])
|
|
{
|
|
$tenantId = session('selected_tenant_id', 1);
|
|
|
|
// 영업 관련 역할을 가진 사용자 조회 (비활성 사용자도 포함 - 관리 목적)
|
|
$query = User::query()
|
|
->whereHas('userRoles', function ($q) use ($tenantId) {
|
|
$q->where('tenant_id', $tenantId)
|
|
->whereHas('role', function ($rq) {
|
|
$rq->whereIn('name', self::SALES_ROLES);
|
|
});
|
|
})
|
|
->with(['parent', 'userRoles.role', 'salesDocuments', 'salesPartner']);
|
|
|
|
// 검색
|
|
if (!empty($filters['search'])) {
|
|
$search = $filters['search'];
|
|
$query->where(function ($q) use ($search) {
|
|
$q->where('name', 'like', "%{$search}%")
|
|
->orWhere('user_id', 'like', "%{$search}%")
|
|
->orWhere('email', 'like', "%{$search}%")
|
|
->orWhere('phone', 'like', "%{$search}%");
|
|
});
|
|
}
|
|
|
|
// 역할 필터
|
|
if (!empty($filters['role'])) {
|
|
$roleName = $filters['role'];
|
|
$query->whereHas('userRoles', function ($q) use ($tenantId, $roleName) {
|
|
$q->where('tenant_id', $tenantId)
|
|
->whereHas('role', function ($rq) use ($roleName) {
|
|
$rq->where('name', $roleName);
|
|
});
|
|
});
|
|
}
|
|
|
|
// 승인 상태 필터
|
|
if (!empty($filters['approval_status'])) {
|
|
$query->where('approval_status', $filters['approval_status']);
|
|
}
|
|
|
|
// 활성 상태 필터 (지정 시에만 적용)
|
|
if (isset($filters['is_active'])) {
|
|
$query->where('is_active', $filters['is_active']);
|
|
}
|
|
|
|
// 유치자(추천인) 필터 - 현재 로그인한 사용자가 유치한 파트너만
|
|
if (!empty($filters['parent_id'])) {
|
|
$query->where('parent_id', $filters['parent_id']);
|
|
}
|
|
|
|
return $query->orderBy('name');
|
|
}
|
|
|
|
/**
|
|
* 추천인(유치자) 후보 목록
|
|
* 승인된 모든 영업파트너가 추천인이 될 수 있음
|
|
*/
|
|
public function getRecommenderCandidates(?int $excludeId = null)
|
|
{
|
|
$tenantId = session('selected_tenant_id', 1);
|
|
|
|
$query = User::query()
|
|
->where('is_active', true)
|
|
->where('approval_status', 'approved')
|
|
->whereHas('userRoles', function ($q) use ($tenantId) {
|
|
$q->where('tenant_id', $tenantId)
|
|
->whereHas('role', function ($rq) {
|
|
$rq->whereIn('name', self::SALES_ROLES);
|
|
});
|
|
});
|
|
|
|
if ($excludeId) {
|
|
$query->where('id', '!=', $excludeId);
|
|
}
|
|
|
|
return $query->orderBy('name')->get();
|
|
}
|
|
|
|
/**
|
|
* 역할 위임 가능한 하위 파트너 목록
|
|
*/
|
|
public function getDelegationCandidates(User $user)
|
|
{
|
|
return User::query()
|
|
->where('parent_id', $user->id)
|
|
->where('is_active', true)
|
|
->where('approval_status', 'approved')
|
|
->orderBy('name')
|
|
->get();
|
|
}
|
|
|
|
/**
|
|
* 영업 역할 목록 조회
|
|
*/
|
|
public function getSalesRoles()
|
|
{
|
|
$tenantId = session('selected_tenant_id', 1);
|
|
|
|
return Role::where('tenant_id', $tenantId)
|
|
->whereIn('name', self::SALES_ROLES)
|
|
->get();
|
|
}
|
|
|
|
/**
|
|
* 통계 조회
|
|
*/
|
|
public function getStats(?int $parentId = null): array
|
|
{
|
|
$tenantId = session('selected_tenant_id', 1);
|
|
|
|
$baseQuery = User::query()
|
|
->whereHas('userRoles', function ($q) use ($tenantId) {
|
|
$q->where('tenant_id', $tenantId)
|
|
->whereHas('role', function ($rq) {
|
|
$rq->whereIn('name', self::SALES_ROLES);
|
|
});
|
|
});
|
|
|
|
// 유치자 기준 필터
|
|
if ($parentId) {
|
|
$baseQuery->where('parent_id', $parentId);
|
|
}
|
|
|
|
return [
|
|
'total' => (clone $baseQuery)->count(),
|
|
'pending' => (clone $baseQuery)->where('approval_status', 'pending')->count(),
|
|
'approved' => (clone $baseQuery)->where('approval_status', 'approved')->count(),
|
|
'sales' => (clone $baseQuery)
|
|
->whereHas('userRoles.role', fn($q) => $q->where('name', 'sales'))
|
|
->count(),
|
|
'manager' => (clone $baseQuery)
|
|
->whereHas('userRoles.role', fn($q) => $q->where('name', 'manager'))
|
|
->count(),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 승인 대기 통계 조회 (본사 관리자용)
|
|
*/
|
|
public function getApprovalStats(): array
|
|
{
|
|
$tenantId = session('selected_tenant_id', 1);
|
|
|
|
$baseQuery = User::query()
|
|
->whereHas('userRoles', function ($q) use ($tenantId) {
|
|
$q->where('tenant_id', $tenantId)
|
|
->whereHas('role', function ($rq) {
|
|
$rq->whereIn('name', self::SALES_ROLES);
|
|
});
|
|
});
|
|
|
|
return [
|
|
'pending' => (clone $baseQuery)->where('approval_status', 'pending')->count(),
|
|
'approved_today' => (clone $baseQuery)
|
|
->where('approval_status', 'approved')
|
|
->whereDate('approved_at', today())
|
|
->count(),
|
|
'rejected_today' => (clone $baseQuery)
|
|
->where('approval_status', 'rejected')
|
|
->whereDate('approved_at', today())
|
|
->count(),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 파트너의 계층 레벨 계산
|
|
*/
|
|
public function getPartnerLevel(User $user): int
|
|
{
|
|
$level = 1;
|
|
$current = $user;
|
|
|
|
while ($current->parent_id !== null) {
|
|
$level++;
|
|
$current = $current->parent;
|
|
|
|
// 무한 루프 방지
|
|
if ($level > 100) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
return $level;
|
|
}
|
|
|
|
/**
|
|
* 하위 파트너 전체 목록 (재귀)
|
|
*/
|
|
public function getAllDescendants(User $user, int $maxDepth = 10): array
|
|
{
|
|
$descendants = [];
|
|
$this->collectDescendants($user, $descendants, 1, $maxDepth);
|
|
return $descendants;
|
|
}
|
|
|
|
private function collectDescendants(User $user, array &$descendants, int $currentDepth, int $maxDepth): void
|
|
{
|
|
if ($currentDepth > $maxDepth) {
|
|
return;
|
|
}
|
|
|
|
$children = User::where('parent_id', $user->id)
|
|
->where('approval_status', 'approved')
|
|
->get();
|
|
|
|
foreach ($children as $child) {
|
|
$descendants[] = [
|
|
'user' => $child,
|
|
'level' => $currentDepth,
|
|
];
|
|
$this->collectDescendants($child, $descendants, $currentDepth + 1, $maxDepth);
|
|
}
|
|
}
|
|
}
|