Files
sam-manage/app/Services/Sales/SalesManagerService.php
pro 9eaf13b950 fix:역할 동기화 시 unique 제약조건 충돌 해결
- soft delete된 레코드가 있을 때 새 레코드 생성 대신 복원
- withTrashed()로 기존 레코드 확인 후 처리
- 불필요한 역할만 선별적으로 삭제

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 11:54:45 +09:00

592 lines
18 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\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', 'recruiter'];
/**
* 영업파트너 생성
*/
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);
// 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']);
}
// 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']);
// 검색
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 (!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(),
'recruiter' => (clone $baseQuery)
->whereHas('userRoles.role', fn($q) => $q->where('name', 'recruiter'))
->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);
}
}
}