diff --git a/.env.example b/.env.example index de90e952..4ad4d45b 100644 --- a/.env.example +++ b/.env.example @@ -81,3 +81,8 @@ FCM_LOG_CHANNEL=stack BAROBILL_CERT_KEY= BAROBILL_CORP_NUM= BAROBILL_TEST_MODE=true + +# Google Cloud Storage (음성 녹음 백업) +GCS_BUCKET_NAME= +GCS_SERVICE_ACCOUNT_PATH=/var/www/sales/apikey/google_service_account.json +GCS_USE_DB_CONFIG=true diff --git a/app/Http/Controllers/Barobill/EtaxController.php b/app/Http/Controllers/Barobill/EtaxController.php index d0a4d0b5..3befd6cf 100644 --- a/app/Http/Controllers/Barobill/EtaxController.php +++ b/app/Http/Controllers/Barobill/EtaxController.php @@ -255,6 +255,7 @@ public function sendToNts(Request $request): JsonResponse if ($result['success']) { $data['invoices'][$invoiceIndex]['status'] = 'sent'; $data['invoices'][$invoiceIndex]['ntsReceiptNo'] = 'NTS-' . date('YmdHis'); + $data['invoices'][$invoiceIndex]['sentAt'] = date('Y-m-d'); file_put_contents($dataFile, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); return response()->json([ @@ -271,6 +272,7 @@ public function sendToNts(Request $request): JsonResponse // 시뮬레이션 $data['invoices'][$invoiceIndex]['status'] = 'sent'; $data['invoices'][$invoiceIndex]['ntsReceiptNo'] = 'NTS-SIM-' . date('YmdHis'); + $data['invoices'][$invoiceIndex]['sentAt'] = date('Y-m-d'); file_put_contents($dataFile, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); return response()->json([ @@ -495,6 +497,7 @@ private function createInvoiceRecord(array $input, string $issueKey, $apiData): 'status' => 'issued', 'memo' => $input['memo'] ?? '', 'createdAt' => date('Y-m-d\TH:i:s'), + 'sentAt' => date('Y-m-d'), 'barobillInvoiceId' => is_numeric($apiData) ? (string)$apiData : '', ]; } diff --git a/app/Http/Controllers/Barobill/HometaxController.php b/app/Http/Controllers/Barobill/HometaxController.php index e62929d6..55b58051 100644 --- a/app/Http/Controllers/Barobill/HometaxController.php +++ b/app/Http/Controllers/Barobill/HometaxController.php @@ -120,6 +120,11 @@ public function index(Request $request): View|Response /** * 매출 세금계산서 목록 조회 (GetPeriodTaxInvoiceSalesList) + * + * 바로빌 API 참고: + * - TaxType: 1(과세+영세), 3(면세) 만 가능 (0은 미지원) + * - DateType: 3(전송일자) 권장 + * - 전체 조회 시 1, 3을 각각 조회하여 합침 */ public function sales(Request $request): JsonResponse { @@ -128,7 +133,7 @@ public function sales(Request $request): JsonResponse $endDate = $request->input('endDate', date('Ymd')); $page = (int)$request->input('page', 1); $limit = (int)$request->input('limit', 50); - $taxType = (int)$request->input('taxType', 0); // 0:전체, 1:과세, 2:영세, 3:면세 + $taxType = (int)$request->input('taxType', 0); // 0:전체, 1:과세+영세, 3:면세 // 현재 테넌트의 바로빌 회원 정보 조회 $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID); @@ -150,63 +155,78 @@ public function sales(Request $request): JsonResponse ]); } - $result = $this->callSoap('GetPeriodTaxInvoiceSalesList', [ - // CorpNum은 callSoap에서 파트너사 사업자번호로 자동 설정 - 'UserID' => $userId, - 'TaxType' => $taxType, - 'DateType' => 1, // 1:작성일 기준 - 'StartDate' => $startDate, - 'EndDate' => $endDate, - 'CountPerPage' => $limit, - 'CurrentPage' => $page - ]); + // TaxType이 0(전체)인 경우 1(과세+영세)과 3(면세)를 각각 조회하여 합침 + $taxTypesToQuery = ($taxType === 0) ? [1, 3] : [$taxType]; + $allInvoices = []; + $totalSummary = ['totalAmount' => 0, 'totalTax' => 0, 'totalSum' => 0, 'count' => 0]; + $lastPagination = ['currentPage' => $page, 'countPerPage' => $limit, 'maxPageNum' => 1, 'maxIndex' => 0]; - if (!$result['success']) { - return response()->json([ - 'success' => false, - 'error' => $result['error'], - 'error_code' => $result['error_code'] ?? null + foreach ($taxTypesToQuery as $queryTaxType) { + $result = $this->callSoap('GetPeriodTaxInvoiceSalesList', [ + 'UserID' => $userId, + 'TaxType' => $queryTaxType, // 1: 과세+영세, 3: 면세 + 'DateType' => 3, // 3: 전송일자 기준 (권장) + 'StartDate' => $startDate, + 'EndDate' => $endDate, + 'CountPerPage' => $limit, + 'CurrentPage' => $page ]); - } - $resultData = $result['data']; + if (!$result['success']) { + // 첫 번째 조회 실패 시 에러 반환 + if (empty($allInvoices)) { + return response()->json([ + 'success' => false, + 'error' => $result['error'], + 'error_code' => $result['error_code'] ?? null + ]); + } + continue; // 이미 일부 데이터가 있으면 계속 진행 + } - // 에러 코드 체크 - $errorCode = $this->checkErrorCode($resultData); - if ($errorCode && !in_array($errorCode, [-60005, -60001])) { - return response()->json([ - 'success' => false, - 'error' => $this->getErrorMessage($errorCode), - 'error_code' => $errorCode - ]); - } + $resultData = $result['data']; + $errorCode = $this->checkErrorCode($resultData); - // 데이터가 없는 경우 - if ($errorCode && in_array($errorCode, [-60005, -60001])) { - return response()->json([ - 'success' => true, - 'data' => [ - 'invoices' => [], - 'summary' => ['totalAmount' => 0, 'totalTax' => 0, 'count' => 0], - 'pagination' => ['currentPage' => 1, 'maxPageNum' => 1] - ] - ]); - } + // 에러 코드 체크 (데이터 없음 외의 에러) + if ($errorCode && !in_array($errorCode, [-60005, -60001])) { + if (empty($allInvoices)) { + return response()->json([ + 'success' => false, + 'error' => $this->getErrorMessage($errorCode), + 'error_code' => $errorCode + ]); + } + continue; + } - // 데이터 파싱 - $parsed = $this->parseInvoices($resultData, 'sales'); + // 데이터가 있는 경우 파싱 + if (!$errorCode || !in_array($errorCode, [-60005, -60001])) { + $parsed = $this->parseInvoices($resultData, 'sales'); + $allInvoices = array_merge($allInvoices, $parsed['invoices']); + $totalSummary['totalAmount'] += $parsed['summary']['totalAmount']; + $totalSummary['totalTax'] += $parsed['summary']['totalTax']; + $totalSummary['totalSum'] += $parsed['summary']['totalSum'] ?? ($parsed['summary']['totalAmount'] + $parsed['summary']['totalTax']); + $totalSummary['count'] += $parsed['summary']['count']; - return response()->json([ - 'success' => true, - 'data' => [ - 'invoices' => $parsed['invoices'], - 'pagination' => [ + // 페이지네이션 정보 업데이트 (마지막 조회 결과 사용) + $lastPagination = [ 'currentPage' => $resultData->CurrentPage ?? 1, 'countPerPage' => $resultData->CountPerPage ?? 50, 'maxPageNum' => $resultData->MaxPageNum ?? 1, 'maxIndex' => $resultData->MaxIndex ?? 0 - ], - 'summary' => $parsed['summary'] + ]; + } + } + + // 작성일 기준으로 정렬 (최신순) + usort($allInvoices, fn($a, $b) => strcmp($b['writeDate'] ?? '', $a['writeDate'] ?? '')); + + return response()->json([ + 'success' => true, + 'data' => [ + 'invoices' => $allInvoices, + 'pagination' => $lastPagination, + 'summary' => $totalSummary ] ]); } catch (\Throwable $e) { @@ -220,6 +240,11 @@ public function sales(Request $request): JsonResponse /** * 매입 세금계산서 목록 조회 (GetPeriodTaxInvoicePurchaseList) + * + * 바로빌 API 참고: + * - TaxType: 1(과세+영세), 3(면세) 만 가능 (0은 미지원) + * - DateType: 3(전송일자) 권장 + * - 전체 조회 시 1, 3을 각각 조회하여 합침 */ public function purchases(Request $request): JsonResponse { @@ -228,7 +253,7 @@ public function purchases(Request $request): JsonResponse $endDate = $request->input('endDate', date('Ymd')); $page = (int)$request->input('page', 1); $limit = (int)$request->input('limit', 50); - $taxType = (int)$request->input('taxType', 0); // 0:전체, 1:과세, 2:영세, 3:면세 + $taxType = (int)$request->input('taxType', 0); // 0:전체, 1:과세+영세, 3:면세 // 현재 테넌트의 바로빌 회원 정보 조회 $tenantId = session('selected_tenant_id', self::HEADQUARTERS_TENANT_ID); @@ -250,63 +275,78 @@ public function purchases(Request $request): JsonResponse ]); } - $result = $this->callSoap('GetPeriodTaxInvoicePurchaseList', [ - // CorpNum은 callSoap에서 파트너사 사업자번호로 자동 설정 - 'UserID' => $userId, - 'TaxType' => $taxType, - 'DateType' => 1, // 1:작성일 기준 - 'StartDate' => $startDate, - 'EndDate' => $endDate, - 'CountPerPage' => $limit, - 'CurrentPage' => $page - ]); + // TaxType이 0(전체)인 경우 1(과세+영세)과 3(면세)를 각각 조회하여 합침 + $taxTypesToQuery = ($taxType === 0) ? [1, 3] : [$taxType]; + $allInvoices = []; + $totalSummary = ['totalAmount' => 0, 'totalTax' => 0, 'totalSum' => 0, 'count' => 0]; + $lastPagination = ['currentPage' => $page, 'countPerPage' => $limit, 'maxPageNum' => 1, 'maxIndex' => 0]; - if (!$result['success']) { - return response()->json([ - 'success' => false, - 'error' => $result['error'], - 'error_code' => $result['error_code'] ?? null + foreach ($taxTypesToQuery as $queryTaxType) { + $result = $this->callSoap('GetPeriodTaxInvoicePurchaseList', [ + 'UserID' => $userId, + 'TaxType' => $queryTaxType, // 1: 과세+영세, 3: 면세 + 'DateType' => 3, // 3: 전송일자 기준 (권장) + 'StartDate' => $startDate, + 'EndDate' => $endDate, + 'CountPerPage' => $limit, + 'CurrentPage' => $page ]); - } - $resultData = $result['data']; + if (!$result['success']) { + // 첫 번째 조회 실패 시 에러 반환 + if (empty($allInvoices)) { + return response()->json([ + 'success' => false, + 'error' => $result['error'], + 'error_code' => $result['error_code'] ?? null + ]); + } + continue; // 이미 일부 데이터가 있으면 계속 진행 + } - // 에러 코드 체크 - $errorCode = $this->checkErrorCode($resultData); - if ($errorCode && !in_array($errorCode, [-60005, -60001])) { - return response()->json([ - 'success' => false, - 'error' => $this->getErrorMessage($errorCode), - 'error_code' => $errorCode - ]); - } + $resultData = $result['data']; + $errorCode = $this->checkErrorCode($resultData); - // 데이터가 없는 경우 - if ($errorCode && in_array($errorCode, [-60005, -60001])) { - return response()->json([ - 'success' => true, - 'data' => [ - 'invoices' => [], - 'summary' => ['totalAmount' => 0, 'totalTax' => 0, 'count' => 0], - 'pagination' => ['currentPage' => 1, 'maxPageNum' => 1] - ] - ]); - } + // 에러 코드 체크 (데이터 없음 외의 에러) + if ($errorCode && !in_array($errorCode, [-60005, -60001])) { + if (empty($allInvoices)) { + return response()->json([ + 'success' => false, + 'error' => $this->getErrorMessage($errorCode), + 'error_code' => $errorCode + ]); + } + continue; + } - // 데이터 파싱 - $parsed = $this->parseInvoices($resultData, 'purchase'); + // 데이터가 있는 경우 파싱 + if (!$errorCode || !in_array($errorCode, [-60005, -60001])) { + $parsed = $this->parseInvoices($resultData, 'purchase'); + $allInvoices = array_merge($allInvoices, $parsed['invoices']); + $totalSummary['totalAmount'] += $parsed['summary']['totalAmount']; + $totalSummary['totalTax'] += $parsed['summary']['totalTax']; + $totalSummary['totalSum'] += $parsed['summary']['totalSum'] ?? ($parsed['summary']['totalAmount'] + $parsed['summary']['totalTax']); + $totalSummary['count'] += $parsed['summary']['count']; - return response()->json([ - 'success' => true, - 'data' => [ - 'invoices' => $parsed['invoices'], - 'pagination' => [ + // 페이지네이션 정보 업데이트 (마지막 조회 결과 사용) + $lastPagination = [ 'currentPage' => $resultData->CurrentPage ?? 1, 'countPerPage' => $resultData->CountPerPage ?? 50, 'maxPageNum' => $resultData->MaxPageNum ?? 1, 'maxIndex' => $resultData->MaxIndex ?? 0 - ], - 'summary' => $parsed['summary'] + ]; + } + } + + // 작성일 기준으로 정렬 (최신순) + usort($allInvoices, fn($a, $b) => strcmp($b['writeDate'] ?? '', $a['writeDate'] ?? '')); + + return response()->json([ + 'success' => true, + 'data' => [ + 'invoices' => $allInvoices, + 'pagination' => $lastPagination, + 'summary' => $totalSummary ] ]); } catch (\Throwable $e) { @@ -482,10 +522,11 @@ public function diagnose(Request $request): JsonResponse ]; // 테스트 2: 매출 세금계산서 조회 (기간: 최근 1개월) + // TaxType: 1(과세+영세), 3(면세) 만 가능 / DateType: 3(전송일자) 권장 $salesResult = $this->callSoap('GetPeriodTaxInvoiceSalesList', [ 'UserID' => $userId, - 'TaxType' => 0, - 'DateType' => 1, + 'TaxType' => 1, // 1: 과세+영세 (0은 미지원) + 'DateType' => 3, // 3: 전송일자 기준 (권장) 'StartDate' => date('Ymd', strtotime('-1 month')), 'EndDate' => date('Ymd'), 'CountPerPage' => 1, @@ -661,8 +702,8 @@ private function getErrorMessage(int $errorCode): string { $messages = [ -10002 => '인증 실패 (-10002). CERTKEY가 올바르지 않거나 만료되었습니다.', - -10008 => '등록되지 않은 사용자입니다 (-10008). 바로빌에 등록된 사업자번호/사용자ID를 확인해주세요.', - -11010 => '세금계산서 조회 권한이 없습니다 (-11010). 바로빌 사이트에서 서비스 권한을 확인해주세요.', + -10008 => '날짜형식이 잘못되었습니다 (-10008). 날짜는 YYYYMMDD 형식(하이픈 제외)으로 입력해주세요.', + -11010 => '과세형태(TaxType)가 잘못되었습니다 (-11010). TaxType은 1(과세+영세) 또는 3(면세)만 가능합니다.', -24005 => 'UserID가 필요합니다 (-24005). 바로빌 회원사 ID를 설정해주세요.', -24006 => '조회된 데이터가 없습니다 (-24006).', -25005 => '조회된 데이터가 없습니다 (-25005).', diff --git a/app/Http/Controllers/Credit/CreditController.php b/app/Http/Controllers/Credit/CreditController.php index e7aeb8cc..eae3efd7 100644 --- a/app/Http/Controllers/Credit/CreditController.php +++ b/app/Http/Controllers/Credit/CreditController.php @@ -95,12 +95,13 @@ public function search(Request $request): JsonResponse $ntsService = new NtsBusinessService(); $ntsResult = $ntsService->getBusinessStatus($companyKey); - // DB에 저장 + // DB에 저장 (tenant_id는 세션에서 가져옴) $inquiry = CreditInquiry::createFromApiResponse( $companyKey, $apiResult, $ntsResult, - auth()->id() + auth()->id(), + session('selected_tenant_id') ); return response()->json([ diff --git a/app/Http/Controllers/Credit/CreditUsageController.php b/app/Http/Controllers/Credit/CreditUsageController.php new file mode 100644 index 00000000..9c4fed19 --- /dev/null +++ b/app/Http/Controllers/Credit/CreditUsageController.php @@ -0,0 +1,262 @@ +header('HX-Request')) { + return response('', 200)->header('HX-Redirect', route('credit.usage.index')); + } + + $user = auth()->user(); + $selectedTenantId = session('selected_tenant_id'); + $isHQ = $selectedTenantId == 1; // 본사(코드브릿지엑스) + + // 기간 필터 (기본값: 현재 월) + $year = $request->input('year', date('Y')); + $month = $request->input('month', date('m')); + $viewType = $request->input('view_type', 'monthly'); // monthly, yearly, custom + + // 기간 설정 + if ($viewType === 'yearly') { + $startDate = "{$year}-01-01 00:00:00"; + $endDate = "{$year}-12-31 23:59:59"; + } elseif ($viewType === 'custom') { + $startDate = $request->input('start_date', date('Y-m-01')) . ' 00:00:00'; + $endDate = $request->input('end_date', date('Y-m-t')) . ' 23:59:59'; + } else { + $startDate = "{$year}-{$month}-01 00:00:00"; + $endDate = date('Y-m-t 23:59:59', strtotime($startDate)); + } + + // 본사는 전체 테넌트 조회, 일반 테넌트는 자기 것만 + if ($isHQ) { + $usageData = $this->getAllTenantsUsage($startDate, $endDate, $viewType, $year); + $tenants = Tenant::whereNull('deleted_at') + ->orderBy('company_name') + ->get(['id', 'company_name', 'code']); + } else { + $usageData = $this->getSingleTenantUsage($selectedTenantId, $startDate, $endDate, $viewType, $year); + $tenants = collect(); + } + + // 선택된 테넌트 필터 + $filterTenantId = $request->input('tenant_id'); + if ($isHQ && $filterTenantId) { + $usageData['details'] = collect($usageData['details'])->filter(function ($item) use ($filterTenantId) { + return $item['tenant_id'] == $filterTenantId; + })->values()->all(); + } + + return view('credit.usage.index', [ + 'isHQ' => $isHQ, + 'usageData' => $usageData, + 'tenants' => $tenants, + 'filters' => [ + 'year' => $year, + 'month' => $month, + 'view_type' => $viewType, + 'start_date' => substr($startDate, 0, 10), + 'end_date' => substr($endDate, 0, 10), + 'tenant_id' => $filterTenantId, + ], + 'policy' => [ + 'free_quota' => self::FREE_MONTHLY_QUOTA, + 'additional_fee' => self::ADDITIONAL_FEE_PER_INQUIRY, + ], + ]); + } + + /** + * 전체 테넌트 사용량 조회 (본사용) + */ + private function getAllTenantsUsage(string $startDate, string $endDate, string $viewType, string $year): array + { + // 테넌트별 조회 건수 + $query = CreditInquiry::select( + 'tenant_id', + DB::raw('COUNT(*) as total_count'), + DB::raw('DATE_FORMAT(inquired_at, "%Y-%m") as month') + ) + ->whereBetween('inquired_at', [$startDate, $endDate]) + ->whereNotNull('tenant_id') + ->groupBy('tenant_id', DB::raw('DATE_FORMAT(inquired_at, "%Y-%m")')); + + $rawData = $query->get(); + + // 테넌트 정보 조회 + $tenantIds = $rawData->pluck('tenant_id')->unique(); + $tenants = Tenant::whereIn('id', $tenantIds)->get()->keyBy('id'); + + // 월별로 그룹화하여 계산 + $monthlyData = []; + foreach ($rawData as $row) { + $tenantId = $row->tenant_id; + $month = $row->month; + + if (!isset($monthlyData[$tenantId])) { + $monthlyData[$tenantId] = []; + } + $monthlyData[$tenantId][$month] = $row->total_count; + } + + // 결과 데이터 생성 + $details = []; + $totalCount = 0; + $totalFee = 0; + + foreach ($monthlyData as $tenantId => $months) { + $tenant = $tenants->get($tenantId); + $tenantTotalCount = 0; + $tenantTotalFee = 0; + + foreach ($months as $month => $count) { + $fee = $this->calculateFee($count); + $tenantTotalCount += $count; + $tenantTotalFee += $fee; + + if ($viewType === 'yearly') { + // 연간 조회 시 월별 상세 표시 + $details[] = [ + 'tenant_id' => $tenantId, + 'tenant_name' => $tenant?->company_name ?? '(삭제됨)', + 'tenant_code' => $tenant?->code ?? '-', + 'month' => $month, + 'count' => $count, + 'free_count' => min($count, self::FREE_MONTHLY_QUOTA), + 'paid_count' => max(0, $count - self::FREE_MONTHLY_QUOTA), + 'fee' => $fee, + ]; + } + } + + if ($viewType !== 'yearly') { + // 월간/기간 조회 시 테넌트별 합계만 + $totalMonthCount = array_sum($months); + $fee = $this->calculateFee($totalMonthCount); + + $details[] = [ + 'tenant_id' => $tenantId, + 'tenant_name' => $tenant?->company_name ?? '(삭제됨)', + 'tenant_code' => $tenant?->code ?? '-', + 'count' => $totalMonthCount, + 'free_count' => min($totalMonthCount, self::FREE_MONTHLY_QUOTA), + 'paid_count' => max(0, $totalMonthCount - self::FREE_MONTHLY_QUOTA), + 'fee' => $fee, + ]; + } + + $totalCount += $tenantTotalCount; + $totalFee += $tenantTotalFee; + } + + // 정렬: 조회 건수 내림차순 + usort($details, fn($a, $b) => $b['count'] - $a['count']); + + return [ + 'total_count' => $totalCount, + 'total_fee' => $totalFee, + 'details' => $details, + ]; + } + + /** + * 단일 테넌트 사용량 조회 + */ + private function getSingleTenantUsage(int $tenantId, string $startDate, string $endDate, string $viewType, string $year): array + { + $tenant = Tenant::find($tenantId); + + // 월별 조회 건수 + $query = CreditInquiry::select( + DB::raw('COUNT(*) as total_count'), + DB::raw('DATE_FORMAT(inquired_at, "%Y-%m") as month') + ) + ->where('tenant_id', $tenantId) + ->whereBetween('inquired_at', [$startDate, $endDate]) + ->groupBy(DB::raw('DATE_FORMAT(inquired_at, "%Y-%m")')) + ->orderBy('month'); + + $rawData = $query->get(); + + $details = []; + $totalCount = 0; + $totalFee = 0; + + foreach ($rawData as $row) { + $count = $row->total_count; + $fee = $this->calculateFee($count); + + $details[] = [ + 'tenant_id' => $tenantId, + 'tenant_name' => $tenant?->company_name ?? '(삭제됨)', + 'month' => $row->month, + 'count' => $count, + 'free_count' => min($count, self::FREE_MONTHLY_QUOTA), + 'paid_count' => max(0, $count - self::FREE_MONTHLY_QUOTA), + 'fee' => $fee, + ]; + + $totalCount += $count; + $totalFee += $fee; + } + + // 연간 조회 시 없는 월도 표시 + if ($viewType === 'yearly') { + $existingMonths = collect($details)->pluck('month')->toArray(); + for ($m = 1; $m <= 12; $m++) { + $monthKey = sprintf('%s-%02d', $year, $m); + if (!in_array($monthKey, $existingMonths)) { + $details[] = [ + 'tenant_id' => $tenantId, + 'tenant_name' => $tenant?->company_name ?? '(삭제됨)', + 'month' => $monthKey, + 'count' => 0, + 'free_count' => 0, + 'paid_count' => 0, + 'fee' => 0, + ]; + } + } + // 월 순서로 정렬 + usort($details, fn($a, $b) => strcmp($a['month'], $b['month'])); + } + + return [ + 'total_count' => $totalCount, + 'total_fee' => $totalFee, + 'details' => $details, + ]; + } + + /** + * 요금 계산 + */ + private function calculateFee(int $count): int + { + $paidCount = max(0, $count - self::FREE_MONTHLY_QUOTA); + return $paidCount * self::ADDITIONAL_FEE_PER_INQUIRY; + } +} diff --git a/app/Http/Controllers/Finance/SalesCommissionController.php b/app/Http/Controllers/Finance/SalesCommissionController.php new file mode 100644 index 00000000..0aa5a8e0 --- /dev/null +++ b/app/Http/Controllers/Finance/SalesCommissionController.php @@ -0,0 +1,381 @@ +header('HX-Request') && !$request->header('HX-Boosted')) { + return response('', 200)->header('HX-Redirect', route('finance.sales-commissions.index')); + } + + // 필터 파라미터 + $year = $request->input('year', now()->year); + $month = $request->input('month', now()->month); + + $filters = [ + 'scheduled_year' => $year, + 'scheduled_month' => $month, + 'status' => $request->input('status'), + 'payment_type' => $request->input('payment_type'), + 'partner_id' => $request->input('partner_id'), + 'search' => $request->input('search'), + ]; + + // 정산 목록 + $commissions = $this->service->getCommissions($filters); + + // 통계 + $stats = $this->service->getSettlementStats($year, $month); + + // 영업파트너 목록 (필터용) + $partners = SalesPartner::with('user') + ->active() + ->orderBy('partner_code') + ->get(); + + // 입금 대기 테넌트 목록 + $pendingTenants = $this->service->getPendingPaymentTenants(); + + return view('finance.sales-commission.index', compact( + 'commissions', + 'stats', + 'partners', + 'pendingTenants', + 'year', + 'month', + 'filters' + )); + } + + /** + * 정산 상세 조회 + */ + public function show(int $id): JsonResponse + { + $commission = $this->service->getCommissionById($id); + + if (!$commission) { + return response()->json([ + 'success' => false, + 'message' => '정산 정보를 찾을 수 없습니다.', + ], 404); + } + + return response()->json([ + 'success' => true, + 'data' => $commission, + ]); + } + + /** + * 정산 상세 모달 (HTMX) + */ + public function detail(int $id): View + { + $commission = $this->service->getCommissionById($id); + + return view('finance.sales-commission.partials.detail-modal', compact('commission')); + } + + /** + * 입금 등록 (수당 생성) + */ + public function registerPayment(Request $request): JsonResponse + { + $validated = $request->validate([ + 'management_id' => 'required|integer|exists:sales_tenant_managements,id', + 'payment_type' => 'required|in:deposit,balance', + 'payment_amount' => 'required|numeric|min:0', + 'payment_date' => 'required|date', + ]); + + try { + $commission = $this->service->createCommission( + $validated['management_id'], + $validated['payment_type'], + $validated['payment_amount'], + $validated['payment_date'] + ); + + return response()->json([ + 'success' => true, + 'message' => '입금이 등록되었습니다.', + 'data' => $commission, + ]); + } catch (\Exception $e) { + return response()->json([ + 'success' => false, + 'message' => $e->getMessage(), + ], 400); + } + } + + /** + * 승인 처리 + */ + public function approve(int $id): JsonResponse + { + try { + $commission = $this->service->approve($id, auth()->id()); + + return response()->json([ + 'success' => true, + 'message' => '승인되었습니다.', + 'data' => $commission, + ]); + } catch (\Exception $e) { + return response()->json([ + 'success' => false, + 'message' => $e->getMessage(), + ], 400); + } + } + + /** + * 일괄 승인 + */ + public function bulkApprove(Request $request): JsonResponse + { + $validated = $request->validate([ + 'ids' => 'required|array|min:1', + 'ids.*' => 'integer|exists:sales_commissions,id', + ]); + + try { + $count = $this->service->bulkApprove($validated['ids'], auth()->id()); + + return response()->json([ + 'success' => true, + 'message' => "{$count}건이 승인되었습니다.", + 'count' => $count, + ]); + } catch (\Exception $e) { + return response()->json([ + 'success' => false, + 'message' => $e->getMessage(), + ], 400); + } + } + + /** + * 지급완료 처리 + */ + public function markPaid(int $id, Request $request): JsonResponse + { + $validated = $request->validate([ + 'bank_reference' => 'nullable|string|max:100', + ]); + + try { + $commission = $this->service->markAsPaid($id, $validated['bank_reference'] ?? null); + + return response()->json([ + 'success' => true, + 'message' => '지급완료 처리되었습니다.', + 'data' => $commission, + ]); + } catch (\Exception $e) { + return response()->json([ + 'success' => false, + 'message' => $e->getMessage(), + ], 400); + } + } + + /** + * 일괄 지급완료 + */ + public function bulkMarkPaid(Request $request): JsonResponse + { + $validated = $request->validate([ + 'ids' => 'required|array|min:1', + 'ids.*' => 'integer|exists:sales_commissions,id', + 'bank_reference' => 'nullable|string|max:100', + ]); + + try { + $count = $this->service->bulkMarkAsPaid($validated['ids'], $validated['bank_reference'] ?? null); + + return response()->json([ + 'success' => true, + 'message' => "{$count}건이 지급완료 처리되었습니다.", + 'count' => $count, + ]); + } catch (\Exception $e) { + return response()->json([ + 'success' => false, + 'message' => $e->getMessage(), + ], 400); + } + } + + /** + * 취소 처리 + */ + public function cancel(int $id): JsonResponse + { + try { + $commission = $this->service->cancel($id); + + return response()->json([ + 'success' => true, + 'message' => '취소되었습니다.', + 'data' => $commission, + ]); + } catch (\Exception $e) { + return response()->json([ + 'success' => false, + 'message' => $e->getMessage(), + ], 400); + } + } + + /** + * 정산 테이블 부분 새로고침 (HTMX) + */ + public function table(Request $request): View + { + $year = $request->input('year', now()->year); + $month = $request->input('month', now()->month); + + $filters = [ + 'scheduled_year' => $year, + 'scheduled_month' => $month, + 'status' => $request->input('status'), + 'payment_type' => $request->input('payment_type'), + 'partner_id' => $request->input('partner_id'), + 'search' => $request->input('search'), + ]; + + $commissions = $this->service->getCommissions($filters); + + return view('finance.sales-commission.partials.commission-table', compact('commissions')); + } + + /** + * 통계 카드 부분 새로고침 (HTMX) + */ + public function stats(Request $request): View + { + $year = $request->input('year', now()->year); + $month = $request->input('month', now()->month); + + $stats = $this->service->getSettlementStats($year, $month); + + return view('finance.sales-commission.partials.stats-cards', compact('stats', 'year', 'month')); + } + + /** + * 입금 등록 폼 (HTMX 모달) + */ + public function paymentForm(Request $request): View + { + $managementId = $request->input('management_id'); + $management = null; + + if ($managementId) { + $management = SalesTenantManagement::with(['tenant', 'salesPartner.user', 'contractProducts.product']) + ->find($managementId); + } + + // 입금 대기 테넌트 목록 + $pendingTenants = $this->service->getPendingPaymentTenants(); + + return view('finance.sales-commission.partials.payment-form', compact('management', 'pendingTenants')); + } + + /** + * 엑셀 다운로드 + */ + public function export(Request $request) + { + $year = $request->input('year', now()->year); + $month = $request->input('month', now()->month); + + $filters = [ + 'scheduled_year' => $year, + 'scheduled_month' => $month, + 'status' => $request->input('status'), + 'payment_type' => $request->input('payment_type'), + 'partner_id' => $request->input('partner_id'), + ]; + + // 전체 데이터 조회 (페이지네이션 없이) + $commissions = SalesCommission::query() + ->with(['tenant', 'partner.user', 'manager']) + ->when(!empty($filters['status']), fn($q) => $q->where('status', $filters['status'])) + ->when(!empty($filters['payment_type']), fn($q) => $q->where('payment_type', $filters['payment_type'])) + ->when(!empty($filters['partner_id']), fn($q) => $q->where('partner_id', $filters['partner_id'])) + ->forScheduledMonth($year, $month) + ->orderBy('scheduled_payment_date') + ->get(); + + // CSV 생성 + $filename = "sales_commission_{$year}_{$month}.csv"; + + $headers = [ + 'Content-Type' => 'text/csv; charset=UTF-8', + 'Content-Disposition' => "attachment; filename=\"{$filename}\"", + ]; + + $callback = function () use ($commissions) { + $file = fopen('php://output', 'w'); + + // BOM for UTF-8 + fprintf($file, chr(0xEF).chr(0xBB).chr(0xBF)); + + // 헤더 + fputcsv($file, [ + '번호', '테넌트', '입금구분', '입금액', '입금일', + '기준액', '영업파트너', '파트너수당', '매니저', '매니저수당', + '지급예정일', '상태', '실제지급일' + ]); + + // 데이터 + foreach ($commissions as $commission) { + fputcsv($file, [ + $commission->id, + $commission->tenant->name ?? $commission->tenant->company_name, + $commission->payment_type_label, + number_format($commission->payment_amount), + $commission->payment_date->format('Y-m-d'), + number_format($commission->base_amount), + $commission->partner?->user?->name ?? '-', + number_format($commission->partner_commission), + $commission->manager?->name ?? '-', + number_format($commission->manager_commission), + $commission->scheduled_payment_date->format('Y-m-d'), + $commission->status_label, + $commission->actual_payment_date?->format('Y-m-d') ?? '-', + ]); + } + + fclose($file); + }; + + return response()->stream($callback, 200, $headers); + } +} diff --git a/app/Http/Controllers/Lab/AIController.php b/app/Http/Controllers/Lab/AIController.php deleted file mode 100644 index cbe17ec2..00000000 --- a/app/Http/Controllers/Lab/AIController.php +++ /dev/null @@ -1,61 +0,0 @@ - A. AI/자동화 메뉴 컨트롤러 - */ -class AIController extends Controller -{ - // 웹 녹음 AI 요약 - public function webRecording(Request $request): View|Response - { - // HTMX 요청 시 전체 페이지 리로드 (스크립트 로딩을 위해) - if ($request->header('HX-Request')) { - return response('', 200)->header('HX-Redirect', route('lab.ai.web-recording')); - } - - return view('lab.ai.web-recording'); - } - - // 회의록 AI 요약 - public function meetingSummary() - { - return view('lab.ai.meeting-summary'); - } - - // 업무협의록 AI 요약 - public function workMemoSummary() - { - return view('lab.ai.work-memo-summary'); - } - - // 운영자용 챗봇 - public function operatorChatbot() - { - return view('lab.ai.operator-chatbot'); - } - - // Vertex RAG 챗봇 - public function vertexRag() - { - return view('lab.ai.vertex-rag'); - } - - // 테넌트 지식 업로드 - public function tenantKnowledge() - { - return view('lab.ai.tenant-knowledge'); - } - - // 테넌트 챗봇 - public function tenantChatbot() - { - return view('lab.ai.tenant-chatbot'); - } -} diff --git a/app/Http/Controllers/Lab/StrategyController.php b/app/Http/Controllers/Lab/StrategyController.php index 5480667a..94785cc5 100644 --- a/app/Http/Controllers/Lab/StrategyController.php +++ b/app/Http/Controllers/Lab/StrategyController.php @@ -25,17 +25,6 @@ private function handlePresentationPage(Request $request, string $routeName): ?R return null; } - /** - * 세무 전략 (장기적 세무전략 프레젠테이션) - */ - public function tax(Request $request): View|Response - { - if ($request->header('HX-Request')) { - return response('', 200)->header('HX-Redirect', route('lab.strategy.tax')); - } - return view('lab.strategy.tax'); - } - /** * 노무 전략 */ @@ -47,28 +36,6 @@ public function labor(Request $request): View|Response return view('lab.strategy.labor'); } - /** - * 채권추심 전략 - */ - public function debt(Request $request): View|Response - { - if ($request->header('HX-Request')) { - return response('', 200)->header('HX-Redirect', route('lab.strategy.debt')); - } - return view('lab.strategy.debt'); - } - - /** - * MRP 해외사례 (presentation layout) - */ - public function mrpOverseas(Request $request): View|Response - { - if ($redirect = $this->handlePresentationPage($request, 'lab.strategy.mrp-overseas')) { - return $redirect; - } - return view('lab.strategy.mrp-overseas'); - } - /** * 상담용 챗봇 전략 */ diff --git a/app/Http/Controllers/PermissionController.php b/app/Http/Controllers/PermissionController.php index 14f3d6e7..1c83db5a 100644 --- a/app/Http/Controllers/PermissionController.php +++ b/app/Http/Controllers/PermissionController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers; -use App\Models\Tenant; +use App\Models\Tenants\Tenant; class PermissionController extends Controller { diff --git a/app/Http/Controllers/Sales/ConsultationController.php b/app/Http/Controllers/Sales/ConsultationController.php new file mode 100644 index 00000000..1f27f719 --- /dev/null +++ b/app/Http/Controllers/Sales/ConsultationController.php @@ -0,0 +1,282 @@ +input('scenario_type', 'sales'); + $stepId = $request->input('step_id'); + + // DB에서 상담 기록 조회 + $consultations = SalesConsultation::getByTenantAndType($tenantId, $scenarioType, $stepId); + + return view('sales.modals.consultation-log', [ + 'tenant' => $tenant, + 'consultations' => $consultations, + 'scenarioType' => $scenarioType, + 'stepId' => $stepId, + ]); + } + + /** + * 텍스트 상담 기록 저장 + */ + public function store(Request $request): JsonResponse + { + $request->validate([ + 'tenant_id' => 'required|integer|exists:tenants,id', + 'scenario_type' => 'required|in:sales,manager', + 'step_id' => 'nullable|integer', + 'content' => 'required|string|max:5000', + ]); + + $consultation = SalesConsultation::createText( + $request->input('tenant_id'), + $request->input('scenario_type'), + $request->input('step_id'), + $request->input('content') + ); + + $consultation->load('creator'); + + return response()->json([ + 'success' => true, + '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(int $consultationId, Request $request): JsonResponse + { + $consultation = SalesConsultation::findOrFail($consultationId); + + // 파일이 있으면 함께 삭제 + $consultation->deleteWithFile(); + + return response()->json([ + 'success' => true, + ]); + } + + /** + * 음성 파일 업로드 + * + * 10MB 이상 파일은 Google Cloud Storage에 업로드하여 본사 연구용으로 보관합니다. + */ + public function uploadAudio(Request $request, GoogleCloudStorageService $gcs): JsonResponse + { + $request->validate([ + 'tenant_id' => 'required|integer|exists:tenants,id', + 'scenario_type' => 'required|in:sales,manager', + 'step_id' => 'nullable|integer', + 'audio' => 'required|file|mimes:webm,mp3,wav,ogg|max:51200', // 50MB + 'transcript' => 'nullable|string|max:10000', + 'duration' => 'nullable|integer', + ]); + + $tenantId = $request->input('tenant_id'); + $scenarioType = $request->input('scenario_type'); + $stepId = $request->input('step_id'); + $transcript = $request->input('transcript'); + $duration = $request->input('duration'); + + // 파일 저장 + $file = $request->file('audio'); + $fileName = 'audio_' . now()->format('Ymd_His') . '_' . uniqid() . '.' . $file->getClientOriginalExtension(); + $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, + $localPath, + $fileName, + $fileSize, + $transcript, + $duration, + $gcsUri + ); + + $consultation->load('creator'); + + return response()->json([ + 'success' => true, + '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'), + 'has_gcs' => !empty($gcsUri), + ], + ]); + } + + /** + * 첨부파일 업로드 + */ + public function uploadFile(Request $request): JsonResponse + { + $request->validate([ + 'tenant_id' => 'required|integer|exists:tenants,id', + 'scenario_type' => 'required|in:sales,manager', + 'step_id' => 'nullable|integer', + 'file' => 'required|file|max:20480', // 20MB + ]); + + $tenantId = $request->input('tenant_id'); + $scenarioType = $request->input('scenario_type'); + $stepId = $request->input('step_id'); + + // 파일 저장 + $file = $request->file('file'); + $originalName = $file->getClientOriginalName(); + $fileName = now()->format('Ymd_His') . '_' . uniqid() . '_' . $originalName; + $path = $file->storeAs("tenant/attachments/{$tenantId}", $fileName, 'local'); + + // DB에 저장 + $consultation = SalesConsultation::createFile( + $tenantId, + $scenarioType, + $stepId, + $path, + $originalName, + $file->getSize(), + $file->getMimeType() + ); + + $consultation->load('creator'); + + return response()->json([ + 'success' => true, + '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(int $fileId, Request $request): JsonResponse + { + return $this->destroy($fileId, $request); + } + + /** + * 오디오 파일 다운로드 + */ + public function downloadAudio(int $consultationId, GoogleCloudStorageService $gcs): BinaryFileResponse|RedirectResponse + { + $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 redirect()->away($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): BinaryFileResponse + { + $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/Http/Controllers/Sales/SalesContractController.php b/app/Http/Controllers/Sales/SalesContractController.php new file mode 100644 index 00000000..f1ed494b --- /dev/null +++ b/app/Http/Controllers/Sales/SalesContractController.php @@ -0,0 +1,91 @@ +validate([ + 'tenant_id' => 'required|exists:tenants,id', + 'products' => 'required|array', + 'products.*.product_id' => 'required|exists:sales_products,id', + 'products.*.category_id' => 'required|exists:sales_product_categories,id', + 'products.*.registration_fee' => 'required|numeric|min:0', + 'products.*.subscription_fee' => 'required|numeric|min:0', + ]); + + try { + DB::transaction(function () use ($validated) { + $tenantId = $validated['tenant_id']; + + // 영업관리 레코드 조회 (없으면 생성) + $management = SalesTenantManagement::findOrCreateByTenant($tenantId); + + // 기존 상품 삭제 + SalesContractProduct::where('tenant_id', $tenantId)->delete(); + + // 새 상품 저장 + foreach ($validated['products'] as $product) { + SalesContractProduct::create([ + 'tenant_id' => $tenantId, + 'management_id' => $management->id, + 'category_id' => $product['category_id'], + 'product_id' => $product['product_id'], + 'registration_fee' => $product['registration_fee'], + 'subscription_fee' => $product['subscription_fee'], + 'discount_rate' => 0, + 'created_by' => auth()->id(), + ]); + } + }); + + return response()->json([ + 'success' => true, + 'message' => '계약 상품이 저장되었습니다.', + ]); + } catch (\Exception $e) { + return response()->json([ + 'success' => false, + 'message' => '저장 중 오류가 발생했습니다.', + ], 500); + } + } + + /** + * 계약 상품 조회 + */ + public function getProducts(int $tenantId): JsonResponse + { + $products = SalesContractProduct::where('tenant_id', $tenantId) + ->with(['product', 'category']) + ->get(); + + $totals = [ + 'development_fee' => $products->sum('development_fee'), + 'subscription_fee' => $products->sum('subscription_fee'), + 'count' => $products->count(), + ]; + + return response()->json([ + 'success' => true, + 'data' => [ + 'products' => $products, + 'totals' => $totals, + ], + ]); + } +} diff --git a/app/Http/Controllers/Sales/SalesDashboardController.php b/app/Http/Controllers/Sales/SalesDashboardController.php index c548cafb..fe077e3d 100644 --- a/app/Http/Controllers/Sales/SalesDashboardController.php +++ b/app/Http/Controllers/Sales/SalesDashboardController.php @@ -3,6 +3,13 @@ namespace App\Http\Controllers\Sales; use App\Http\Controllers\Controller; +use App\Models\Sales\SalesPartner; +use App\Models\Sales\SalesTenantManagement; +use App\Models\Sales\TenantProspect; +use App\Models\Tenants\Tenant; +use App\Models\User; +use App\Services\SalesCommissionService; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\View\View; @@ -11,6 +18,10 @@ */ class SalesDashboardController extends Controller { + public function __construct( + private SalesCommissionService $commissionService + ) {} + /** * 대시보드 화면 */ @@ -18,6 +29,9 @@ public function index(Request $request): View { $data = $this->getDashboardData($request); + // 영업파트너 수당 정보 추가 + $data = array_merge($data, $this->getCommissionData()); + return view('sales.dashboard.index', $data); } @@ -93,11 +107,39 @@ private function getDashboardData(Request $request): array 'confirmed_commission' => 0, // 확정 가입비 수당 ]; + // 테넌트 목록 (가망고객에서 전환된 테넌트만) + // 전환된 가망고객의 tenant_id 목록 조회 + $convertedTenantIds = TenantProspect::whereNotNull('tenant_id') + ->where('status', TenantProspect::STATUS_CONVERTED) + ->pluck('tenant_id') + ->toArray(); + + // 전환된 테넌트만 조회 (최신순, 페이지네이션) + $tenants = Tenant::whereIn('id', $convertedTenantIds) + ->orderBy('created_at', 'desc') + ->paginate(10) + ->withQueryString(); + + // 각 테넌트의 영업 관리 정보 로드 + $tenantIds = $tenants->pluck('id')->toArray(); + $managements = SalesTenantManagement::whereIn('tenant_id', $tenantIds) + ->with('manager') + ->get() + ->keyBy('tenant_id'); + + // 내가 유치한 영업파트너 목록 (드롭다운용) + $allManagers = auth()->user()->children() + ->where('is_active', true) + ->get(['id', 'name', 'email']); + return compact( 'stats', 'commissionByRole', 'totalCommissionRatio', 'tenantStats', + 'tenants', + 'managements', + 'allManagers', 'period', 'year', 'month', @@ -105,4 +147,123 @@ private function getDashboardData(Request $request): array 'endDate' ); } + + /** + * 매니저 지정 변경 + */ + public function assignManager(int $tenantId, Request $request): JsonResponse + { + $request->validate([ + 'manager_id' => 'required|integer', + ]); + + $tenant = Tenant::findOrFail($tenantId); + $managerId = $request->input('manager_id'); + + // 테넌트 영업 관리 정보 조회 또는 생성 + $management = SalesTenantManagement::findOrCreateByTenant($tenantId); + + if ($managerId === 0) { + // 본인으로 설정 (현재 로그인 사용자) + $manager = auth()->user(); + $management->update([ + 'manager_user_id' => $manager->id, + ]); + } else { + // 특정 매니저 지정 + $manager = User::find($managerId); + if (!$manager) { + return response()->json([ + 'success' => false, + 'message' => '매니저를 찾을 수 없습니다.', + ], 404); + } + + $management->update([ + 'manager_user_id' => $manager->id, + ]); + } + + return response()->json([ + 'success' => true, + 'manager' => [ + 'id' => $manager->id, + 'name' => $manager->name, + ], + ]); + } + + /** + * 테넌트 리스트 부분 새로고침 (HTMX) + */ + public function refreshTenantList(Request $request): View + { + // 전환된 가망고객의 tenant_id 목록 조회 + $convertedTenantIds = TenantProspect::whereNotNull('tenant_id') + ->where('status', TenantProspect::STATUS_CONVERTED) + ->pluck('tenant_id') + ->toArray(); + + // 전환된 테넌트만 조회 (최신순, 페이지네이션) + $tenants = Tenant::whereIn('id', $convertedTenantIds) + ->orderBy('created_at', 'desc') + ->paginate(10) + ->withQueryString(); + + // 각 테넌트의 영업 관리 정보 로드 + $tenantIds = $tenants->pluck('id')->toArray(); + $managements = SalesTenantManagement::whereIn('tenant_id', $tenantIds) + ->with('manager') + ->get() + ->keyBy('tenant_id'); + + // 내가 유치한 영업파트너 목록 (드롭다운용) + $allManagers = auth()->user()->children() + ->where('is_active', true) + ->get(['id', 'name', 'email']); + + return view('sales.dashboard.partials.tenant-list', compact( + 'tenants', + 'managements', + 'allManagers' + )); + } + + /** + * 매니저 목록 조회 (드롭다운용) + */ + public function getManagers(Request $request): JsonResponse + { + // HQ 테넌트의 사용자 중 매니저 역할이 있는 사용자 조회 + $managers = User::whereHas('tenants', function ($query) { + $query->where('tenant_type', 'HQ'); + })->get(['id', 'name', 'email']); + + return response()->json([ + 'success' => true, + 'managers' => $managers, + ]); + } + + /** + * 영업파트너 수당 정보 조회 + */ + private function getCommissionData(): array + { + $user = auth()->user(); + $commissionSummary = []; + $recentCommissions = collect(); + + // 현재 사용자가 영업파트너인지 확인 + $partner = SalesPartner::where('user_id', $user->id) + ->where('status', 'active') + ->first(); + + if ($partner) { + $commissionSummary = $this->commissionService->getPartnerCommissionSummary($partner->id); + $recentCommissions = $this->commissionService->getRecentCommissions($partner->id, 5); + } + + return compact('commissionSummary', 'recentCommissions', 'partner'); + } } diff --git a/app/Http/Controllers/Sales/SalesManagerController.php b/app/Http/Controllers/Sales/SalesManagerController.php index ba828dfe..38806bf4 100644 --- a/app/Http/Controllers/Sales/SalesManagerController.php +++ b/app/Http/Controllers/Sales/SalesManagerController.php @@ -73,14 +73,9 @@ public function store(Request $request) 'documents.*.description' => 'nullable|string|max:500', ]); - // 등록자가 영업파트너인 경우 자동으로 추천인(parent)으로 설정 - // 본사 관리자가 등록하는 경우 parent_id는 null (최상위 파트너) - $currentUser = auth()->user(); - $isSalesPartner = $currentUser->userRoles() - ->whereHas('role', fn($q) => $q->whereIn('name', ['sales', 'manager', 'recruiter'])) - ->exists(); - - $validated['parent_id'] = $isSalesPartner ? $currentUser->id : null; + // 등록자를 추천인(parent)으로 자동 설정 + // 본사 관리자가 등록해도 해당 관리자가 추천인이 됨 + $validated['parent_id'] = auth()->id(); // 문서 배열 구성 $documents = []; @@ -122,6 +117,38 @@ public function show(int $id): View return view('sales.managers.show', compact('partner', 'level', 'children', 'delegationCandidates')); } + /** + * 상세 모달용 + */ + public function modalShow(int $id): View + { + $partner = User::with(['parent', 'children', 'userRoles.role', 'salesDocuments', 'approver']) + ->findOrFail($id); + + $level = $this->service->getPartnerLevel($partner); + + $children = User::where('parent_id', $partner->id) + ->with('userRoles.role') + ->get(); + + return view('sales.managers.partials.show-modal', compact('partner', 'level', 'children')); + } + + /** + * 수정 모달용 + */ + public function modalEdit(int $id): View + { + $partner = User::with(['userRoles.role', 'salesDocuments', 'parent'])->findOrFail($id); + $roles = $this->service->getSalesRoles(); + $currentRoleIds = $partner->userRoles->pluck('role_id')->toArray(); + $documentTypes = SalesManagerDocument::DOCUMENT_TYPES; + + return view('sales.managers.partials.edit-modal', compact( + 'partner', 'roles', 'currentRoleIds', 'documentTypes' + )); + } + /** * 수정 폼 */ diff --git a/app/Http/Controllers/Sales/SalesProductController.php b/app/Http/Controllers/Sales/SalesProductController.php new file mode 100644 index 00000000..be331146 --- /dev/null +++ b/app/Http/Controllers/Sales/SalesProductController.php @@ -0,0 +1,279 @@ +header('HX-Request')) { + return response('', 200)->header('HX-Redirect', route('sales.products.index')); + } + + $categories = SalesProductCategory::active() + ->ordered() + ->with(['products' => fn($q) => $q->ordered()]) + ->get(); + + $currentCategoryCode = $request->input('category', $categories->first()?->code); + $currentCategory = $categories->firstWhere('code', $currentCategoryCode) ?? $categories->first(); + + return view('sales.products.index', compact('categories', 'currentCategory')); + } + + /** + * 상품 목록 (HTMX용) + */ + public function productList(Request $request): View + { + $categoryCode = $request->input('category'); + $category = SalesProductCategory::where('code', $categoryCode) + ->with(['products' => fn($q) => $q->ordered()]) + ->first(); + + return view('sales.products.partials.product-list', compact('category')); + } + + /** + * 상품 저장 + */ + public function store(Request $request): JsonResponse + { + $validated = $request->validate([ + 'category_id' => 'required|exists:sales_product_categories,id', + 'code' => 'required|string|max:50', + 'name' => 'required|string|max:100', + 'description' => 'nullable|string', + 'development_fee' => 'required|numeric|min:0', + 'registration_fee' => 'required|numeric|min:0', + 'subscription_fee' => 'required|numeric|min:0', + 'partner_commission_rate' => 'nullable|numeric|min:0|max:100', + 'manager_commission_rate' => 'nullable|numeric|min:0|max:100', + 'allow_flexible_pricing' => 'boolean', + 'is_required' => 'boolean', + ]); + + // 코드 중복 체크 + $exists = SalesProduct::where('category_id', $validated['category_id']) + ->where('code', $validated['code']) + ->exists(); + + if ($exists) { + return response()->json([ + 'success' => false, + 'message' => '이미 존재하는 상품 코드입니다.', + ], 422); + } + + // 순서 설정 (마지막) + $maxOrder = SalesProduct::where('category_id', $validated['category_id'])->max('display_order') ?? 0; + $validated['display_order'] = $maxOrder + 1; + $validated['partner_commission_rate'] = $validated['partner_commission_rate'] ?? 20.00; + $validated['manager_commission_rate'] = $validated['manager_commission_rate'] ?? 5.00; + + $product = SalesProduct::create($validated); + + return response()->json([ + 'success' => true, + 'message' => '상품이 등록되었습니다.', + 'product' => $product, + ]); + } + + /** + * 상품 수정 + */ + public function update(Request $request, int $id): JsonResponse + { + $product = SalesProduct::findOrFail($id); + + $validated = $request->validate([ + 'name' => 'sometimes|string|max:100', + 'description' => 'nullable|string', + 'development_fee' => 'sometimes|numeric|min:0', + 'registration_fee' => 'sometimes|numeric|min:0', + 'subscription_fee' => 'sometimes|numeric|min:0', + 'partner_commission_rate' => 'nullable|numeric|min:0|max:100', + 'manager_commission_rate' => 'nullable|numeric|min:0|max:100', + 'allow_flexible_pricing' => 'boolean', + 'is_required' => 'boolean', + 'is_active' => 'boolean', + ]); + + $product->update($validated); + + return response()->json([ + 'success' => true, + 'message' => '상품이 수정되었습니다.', + 'product' => $product->fresh(), + ]); + } + + /** + * 상품 삭제 (soft delete) + */ + public function destroy(int $id): JsonResponse + { + $product = SalesProduct::findOrFail($id); + $product->delete(); + + return response()->json([ + 'success' => true, + 'message' => '상품이 삭제되었습니다.', + ]); + } + + /** + * 활성화 토글 + */ + public function toggleActive(int $id): JsonResponse + { + $product = SalesProduct::findOrFail($id); + $product->update(['is_active' => !$product->is_active]); + + return response()->json([ + 'success' => true, + 'message' => $product->is_active ? '상품이 활성화되었습니다.' : '상품이 비활성화되었습니다.', + 'is_active' => $product->is_active, + ]); + } + + /** + * 순서 변경 + */ + public function reorder(Request $request): JsonResponse + { + $validated = $request->validate([ + 'orders' => 'required|array', + 'orders.*.id' => 'required|exists:sales_products,id', + 'orders.*.order' => 'required|integer|min:0', + ]); + + foreach ($validated['orders'] as $item) { + SalesProduct::where('id', $item['id'])->update(['display_order' => $item['order']]); + } + + return response()->json([ + 'success' => true, + 'message' => '순서가 변경되었습니다.', + ]); + } + + // ==================== 카테고리 관리 ==================== + + /** + * 카테고리 목록 + */ + public function categories(): JsonResponse + { + $categories = SalesProductCategory::ordered()->get(); + + return response()->json([ + 'success' => true, + 'data' => $categories, + ]); + } + + /** + * 카테고리 생성 + */ + public function storeCategory(Request $request): JsonResponse + { + $validated = $request->validate([ + 'code' => 'required|string|max:50|unique:sales_product_categories,code', + 'name' => 'required|string|max:100', + 'description' => 'nullable|string', + 'base_storage' => 'nullable|string|max:20', + ]); + + $maxOrder = SalesProductCategory::max('display_order') ?? 0; + $validated['display_order'] = $maxOrder + 1; + + $category = SalesProductCategory::create($validated); + + return response()->json([ + 'success' => true, + 'message' => '카테고리가 생성되었습니다.', + 'category' => $category, + ]); + } + + /** + * 카테고리 수정 + */ + public function updateCategory(Request $request, int $id): JsonResponse + { + $category = SalesProductCategory::findOrFail($id); + + $validated = $request->validate([ + 'name' => 'sometimes|string|max:100', + 'description' => 'nullable|string', + 'base_storage' => 'nullable|string|max:20', + 'is_active' => 'boolean', + ]); + + $category->update($validated); + + return response()->json([ + 'success' => true, + 'message' => '카테고리가 수정되었습니다.', + 'category' => $category->fresh(), + ]); + } + + /** + * 카테고리 삭제 + */ + public function deleteCategory(int $id): JsonResponse + { + $category = SalesProductCategory::findOrFail($id); + + // 상품이 있으면 삭제 불가 + if ($category->products()->exists()) { + return response()->json([ + 'success' => false, + 'message' => '상품이 있는 카테고리는 삭제할 수 없습니다.', + ], 422); + } + + $category->delete(); + + return response()->json([ + 'success' => true, + 'message' => '카테고리가 삭제되었습니다.', + ]); + } + + // ==================== API (영업 시나리오용) ==================== + + /** + * 상품 목록 API (카테고리 포함) + */ + public function getProductsApi(): JsonResponse + { + $categories = SalesProductCategory::active() + ->ordered() + ->with(['products' => fn($q) => $q->active()->ordered()]) + ->get(); + + return response()->json([ + 'success' => true, + 'data' => $categories, + ]); + } +} diff --git a/app/Http/Controllers/Sales/SalesScenarioController.php b/app/Http/Controllers/Sales/SalesScenarioController.php new file mode 100644 index 00000000..d2ec9559 --- /dev/null +++ b/app/Http/Controllers/Sales/SalesScenarioController.php @@ -0,0 +1,167 @@ +input('step', 1); + $icons = config('sales_scenario.icons'); + + // 테넌트 영업 관리 정보 조회 또는 생성 + $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')) { + return view('sales.modals.scenario-step', [ + 'tenant' => $tenant, + 'steps' => $steps, + 'currentStep' => $currentStep, + 'step' => collect($steps)->firstWhere('id', $currentStep), + 'progress' => $progress, + 'scenarioType' => 'sales', + 'icons' => $icons, + 'management' => $management, + ]); + } + + return view('sales.modals.scenario-modal', [ + 'tenant' => $tenant, + 'steps' => $steps, + 'currentStep' => $currentStep, + 'progress' => $progress, + 'scenarioType' => 'sales', + 'icons' => $icons, + 'management' => $management, + ]); + } + + /** + * 매니저 시나리오 모달 뷰 + */ + public function managerScenario(int $tenantId, Request $request): View|Response + { + $tenant = Tenant::findOrFail($tenantId); + $steps = config('sales_scenario.manager_steps'); + $currentStep = (int) $request->input('step', 1); + $icons = config('sales_scenario.icons'); + + // 테넌트 영업 관리 정보 조회 또는 생성 + $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')) { + return view('sales.modals.scenario-step', [ + 'tenant' => $tenant, + 'steps' => $steps, + 'currentStep' => $currentStep, + 'step' => collect($steps)->firstWhere('id', $currentStep), + 'progress' => $progress, + 'scenarioType' => 'manager', + 'icons' => $icons, + 'management' => $management, + ]); + } + + return view('sales.modals.scenario-modal', [ + 'tenant' => $tenant, + 'steps' => $steps, + 'currentStep' => $currentStep, + 'progress' => $progress, + 'scenarioType' => 'manager', + 'icons' => $icons, + 'management' => $management, + ]); + } + + /** + * 체크리스트 항목 토글 (HTMX) + */ + public function toggleChecklist(Request $request): JsonResponse + { + $request->validate([ + 'tenant_id' => 'required|integer|exists:tenants,id', + 'scenario_type' => 'required|in:sales,manager', + 'step_id' => 'required|integer', + 'checkpoint_id' => 'required|string', + 'checked' => 'required|boolean', + ]); + + $tenantId = $request->input('tenant_id'); + $scenarioType = $request->input('scenario_type'); + $stepId = $request->input('step_id'); + $checkpointId = $request->input('checkpoint_id'); + $checked = $request->boolean('checked'); + + // 체크리스트 토글 (DB에 저장) + SalesScenarioChecklist::toggle( + $tenantId, + $scenarioType, + $stepId, + $checkpointId, + $checked, + auth()->id() + ); + + // 진행률 재계산 + $steps = config("sales_scenario.{$scenarioType}_steps"); + $progress = SalesScenarioChecklist::calculateProgress($tenantId, $scenarioType, $steps); + + // 테넌트 영업 관리 정보 업데이트 + $management = SalesTenantManagement::findOrCreateByTenant($tenantId); + $management->updateProgress($scenarioType, $progress['percentage']); + + return response()->json([ + 'success' => true, + 'progress' => $progress, + 'checked' => $checked, + ]); + } + + /** + * 진행률 조회 + */ + public function getProgress(int $tenantId, string $type): JsonResponse + { + $steps = config("sales_scenario.{$type}_steps"); + $progress = SalesScenarioChecklist::calculateProgress($tenantId, $type, $steps); + + return response()->json([ + 'success' => true, + 'progress' => $progress, + ]); + } +} diff --git a/app/Http/Controllers/Sales/TenantProspectController.php b/app/Http/Controllers/Sales/TenantProspectController.php index d44472a8..ef907777 100644 --- a/app/Http/Controllers/Sales/TenantProspectController.php +++ b/app/Http/Controllers/Sales/TenantProspectController.php @@ -65,6 +65,7 @@ public function store(Request $request) 'contact_email' => 'nullable|email|max:100', 'address' => 'nullable|string|max:500', 'business_card' => 'nullable|image|max:5120', + 'business_card_image_data' => 'nullable|string', 'memo' => 'nullable|string|max:1000', ]); @@ -79,9 +80,14 @@ public function store(Request $request) // 등록자는 현재 로그인 사용자 $validated['registered_by'] = auth()->id(); + // Base64 이미지 데이터가 있으면 전달 + $businessCardBase64 = $validated['business_card_image_data'] ?? null; + unset($validated['business_card_image_data']); + $this->service->register( $validated, - $request->file('business_card') + $request->file('business_card'), + $businessCardBase64 ); return redirect()->route('sales.prospects.index') @@ -214,6 +220,27 @@ public function checkBusinessNumber(Request $request) return response()->json($result); } + /** + * 모달용 상세 정보 + */ + public function modalShow(int $id): View + { + $prospect = TenantProspect::with(['registeredBy', 'tenant', 'convertedBy']) + ->findOrFail($id); + + return view('sales.prospects.partials.show-modal', compact('prospect')); + } + + /** + * 모달용 수정 폼 + */ + public function modalEdit(int $id): View + { + $prospect = TenantProspect::findOrFail($id); + + return view('sales.prospects.partials.edit-modal', compact('prospect')); + } + /** * 첨부 이미지 삭제 (AJAX) */ diff --git a/app/Http/Controllers/System/AiConfigController.php b/app/Http/Controllers/System/AiConfigController.php index 4f7c052e..b3b174c3 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의 다른 설정 비활성화 @@ -163,19 +204,35 @@ public function test(Request $request): JsonResponse { $validated = $request->validate([ 'provider' => 'required|string|in:gemini,claude,openai', - 'api_key' => 'required|string', + 'api_key' => 'nullable|string', 'model' => 'required|string', 'base_url' => 'nullable|string', + 'auth_type' => 'nullable|string|in:api_key,vertex_ai', + 'project_id' => 'nullable|string', + 'region' => 'nullable|string', + 'service_account_path' => 'nullable|string', ]); try { $provider = $validated['provider']; - $apiKey = $validated['api_key']; $model = $validated['model']; - $baseUrl = $validated['base_url'] ?? AiConfig::DEFAULT_BASE_URLS[$provider]; + $authType = $validated['auth_type'] ?? 'api_key'; if ($provider === 'gemini') { - $result = $this->testGemini($baseUrl, $model, $apiKey); + if ($authType === 'vertex_ai') { + // Vertex AI (서비스 계정) 방식 + $result = $this->testGeminiVertexAi( + $model, + $validated['project_id'] ?? '', + $validated['region'] ?? 'us-central1', + $validated['service_account_path'] ?? '' + ); + } else { + // API 키 방식 + $apiKey = $validated['api_key'] ?? ''; + $baseUrl = $validated['base_url'] ?? AiConfig::DEFAULT_BASE_URLS[$provider]; + $result = $this->testGemini($baseUrl, $model, $apiKey); + } } else { return response()->json([ 'ok' => false, @@ -193,7 +250,7 @@ public function test(Request $request): JsonResponse } /** - * Gemini API 테스트 + * Gemini API 테스트 (API 키 방식) */ private function testGemini(string $baseUrl, string $model, string $apiKey): array { @@ -225,4 +282,223 @@ private function testGemini(string $baseUrl, string $model, string $apiKey): arr 'error' => 'API 응답 오류: ' . $response->status(), ]; } + + /** + * Gemini API 테스트 (Vertex AI 방식) + */ + private function testGeminiVertexAi(string $model, string $projectId, string $region, string $serviceAccountPath): array + { + // 필수 파라미터 검증 + if (empty($projectId)) { + return ['ok' => false, 'error' => '프로젝트 ID가 필요합니다.']; + } + + if (empty($serviceAccountPath)) { + return ['ok' => false, 'error' => '서비스 계정 파일 경로가 필요합니다.']; + } + + if (!file_exists($serviceAccountPath)) { + return ['ok' => false, 'error' => "서비스 계정 파일을 찾을 수 없습니다: {$serviceAccountPath}"]; + } + + // 서비스 계정 JSON 로드 + $serviceAccount = json_decode(file_get_contents($serviceAccountPath), true); + if (!$serviceAccount || empty($serviceAccount['client_email']) || empty($serviceAccount['private_key'])) { + return ['ok' => false, 'error' => '서비스 계정 파일 형식이 올바르지 않습니다.']; + } + + // OAuth 토큰 획득 + $accessToken = $this->getVertexAiAccessToken($serviceAccount); + if (!$accessToken) { + return ['ok' => false, 'error' => 'OAuth 토큰 획득 실패. 서비스 계정 권한을 확인하세요.']; + } + + // Vertex AI 엔드포인트 URL 구성 + $url = "https://{$region}-aiplatform.googleapis.com/v1/projects/{$projectId}/locations/{$region}/publishers/google/models/{$model}:generateContent"; + + $response = \Illuminate\Support\Facades\Http::timeout(30) + ->withHeaders([ + 'Authorization' => 'Bearer ' . $accessToken, + 'Content-Type' => 'application/json', + ]) + ->post($url, [ + 'contents' => [ + [ + 'role' => 'user', + 'parts' => [ + ['text' => '안녕하세요. 테스트입니다. "OK"라고만 응답해주세요.'], + ], + ], + ], + 'generationConfig' => [ + 'temperature' => 0, + 'maxOutputTokens' => 10, + ], + ]); + + if ($response->successful()) { + return [ + 'ok' => true, + 'message' => 'Vertex AI 연결 테스트 성공', + ]; + } + + // 상세 오류 메시지 추출 + $errorBody = $response->json(); + $errorMsg = $errorBody['error']['message'] ?? ('HTTP ' . $response->status()); + + return [ + 'ok' => false, + 'error' => "Vertex AI 오류: {$errorMsg}", + ]; + } + + /** + * Vertex AI OAuth 토큰 획득 + */ + private function getVertexAiAccessToken(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/cloud-platform', + '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; + } + + /** + * 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/Credit/CreditInquiry.php b/app/Models/Credit/CreditInquiry.php index 97edc1c6..374d6011 100644 --- a/app/Models/Credit/CreditInquiry.php +++ b/app/Models/Credit/CreditInquiry.php @@ -12,6 +12,7 @@ class CreditInquiry extends Model { protected $fillable = [ + 'tenant_id', 'inquiry_key', 'company_key', 'company_name', @@ -175,12 +176,14 @@ public function getNtsStatusLabelAttribute(): string * @param array $apiResult 쿠콘 API 결과 * @param array|null $ntsResult 국세청 API 결과 * @param int|null $userId 조회자 ID + * @param int|null $tenantId 테넌트 ID */ public static function createFromApiResponse( string $companyKey, array $apiResult, ?array $ntsResult = null, - ?int $userId = null + ?int $userId = null, + ?int $tenantId = null ): self { // 요약 정보에서 건수 추출 $summaryData = $apiResult['summary']['data'] ?? []; @@ -238,6 +241,7 @@ public static function createFromApiResponse( }; return self::create([ + 'tenant_id' => $tenantId, 'company_key' => $companyKey, 'user_id' => $userId, 'inquired_at' => now(), diff --git a/app/Models/Sales/SalesCommission.php b/app/Models/Sales/SalesCommission.php new file mode 100644 index 00000000..28c1bccb --- /dev/null +++ b/app/Models/Sales/SalesCommission.php @@ -0,0 +1,293 @@ + '대기', + self::STATUS_APPROVED => '승인', + self::STATUS_PAID => '지급완료', + self::STATUS_CANCELLED => '취소', + ]; + + /** + * 입금 구분 라벨 + */ + public static array $paymentTypeLabels = [ + self::PAYMENT_DEPOSIT => '계약금', + self::PAYMENT_BALANCE => '잔금', + ]; + + protected $fillable = [ + 'tenant_id', + 'management_id', + 'payment_type', + 'payment_amount', + 'payment_date', + 'base_amount', + 'partner_rate', + 'manager_rate', + 'partner_commission', + 'manager_commission', + 'scheduled_payment_date', + 'status', + 'actual_payment_date', + 'partner_id', + 'manager_user_id', + 'notes', + 'bank_reference', + 'approved_by', + 'approved_at', + ]; + + protected $casts = [ + 'payment_amount' => 'decimal:2', + 'base_amount' => 'decimal:2', + 'partner_rate' => 'decimal:2', + 'manager_rate' => 'decimal:2', + 'partner_commission' => 'decimal:2', + 'manager_commission' => 'decimal:2', + 'payment_date' => 'date', + 'scheduled_payment_date' => 'date', + 'actual_payment_date' => 'date', + 'approved_at' => 'datetime', + ]; + + /** + * 테넌트 관계 + */ + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + /** + * 영업관리 관계 + */ + public function management(): BelongsTo + { + return $this->belongsTo(SalesTenantManagement::class, 'management_id'); + } + + /** + * 영업파트너 관계 + */ + public function partner(): BelongsTo + { + return $this->belongsTo(SalesPartner::class, 'partner_id'); + } + + /** + * 매니저(사용자) 관계 + */ + public function manager(): BelongsTo + { + return $this->belongsTo(User::class, 'manager_user_id'); + } + + /** + * 상세 내역 관계 + */ + public function details(): HasMany + { + return $this->hasMany(SalesCommissionDetail::class, 'commission_id'); + } + + /** + * 승인자 관계 + */ + public function approver(): BelongsTo + { + return $this->belongsTo(User::class, 'approved_by'); + } + + /** + * 상태 라벨 Accessor + */ + public function getStatusLabelAttribute(): string + { + return self::$statusLabels[$this->status] ?? $this->status; + } + + /** + * 입금 구분 라벨 Accessor + */ + public function getPaymentTypeLabelAttribute(): string + { + return self::$paymentTypeLabels[$this->payment_type] ?? $this->payment_type; + } + + /** + * 총 수당액 Accessor + */ + public function getTotalCommissionAttribute(): float + { + return $this->partner_commission + $this->manager_commission; + } + + /** + * 지급예정일 계산 (입금일 익월 10일) + */ + public static function calculateScheduledPaymentDate(Carbon $paymentDate): Carbon + { + return $paymentDate->copy()->addMonth()->day(10); + } + + /** + * 승인 처리 + */ + public function approve(int $approverId): bool + { + if ($this->status !== self::STATUS_PENDING) { + return false; + } + + return $this->update([ + 'status' => self::STATUS_APPROVED, + 'approved_by' => $approverId, + 'approved_at' => now(), + ]); + } + + /** + * 지급완료 처리 + */ + public function markAsPaid(?string $bankReference = null): bool + { + if ($this->status !== self::STATUS_APPROVED) { + return false; + } + + return $this->update([ + 'status' => self::STATUS_PAID, + 'actual_payment_date' => now()->format('Y-m-d'), + 'bank_reference' => $bankReference, + ]); + } + + /** + * 취소 처리 + */ + public function cancel(): bool + { + if ($this->status === self::STATUS_PAID) { + return false; + } + + return $this->update([ + 'status' => self::STATUS_CANCELLED, + ]); + } + + /** + * 대기 상태 스코프 + */ + public function scopePending(Builder $query): Builder + { + return $query->where('status', self::STATUS_PENDING); + } + + /** + * 승인완료 스코프 + */ + public function scopeApproved(Builder $query): Builder + { + return $query->where('status', self::STATUS_APPROVED); + } + + /** + * 지급완료 스코프 + */ + public function scopePaid(Builder $query): Builder + { + return $query->where('status', self::STATUS_PAID); + } + + /** + * 특정 영업파트너 스코프 + */ + public function scopeForPartner(Builder $query, int $partnerId): Builder + { + return $query->where('partner_id', $partnerId); + } + + /** + * 특정 매니저 스코프 + */ + public function scopeForManager(Builder $query, int $managerUserId): Builder + { + return $query->where('manager_user_id', $managerUserId); + } + + /** + * 특정 월 지급예정 스코프 + */ + public function scopeForScheduledMonth(Builder $query, int $year, int $month): Builder + { + return $query->whereYear('scheduled_payment_date', $year) + ->whereMonth('scheduled_payment_date', $month); + } + + /** + * 특정 기간 입금 스코프 + */ + public function scopePaymentDateBetween(Builder $query, string $startDate, string $endDate): Builder + { + return $query->whereBetween('payment_date', [$startDate, $endDate]); + } +} diff --git a/app/Models/Sales/SalesCommissionDetail.php b/app/Models/Sales/SalesCommissionDetail.php new file mode 100644 index 00000000..214a758d --- /dev/null +++ b/app/Models/Sales/SalesCommissionDetail.php @@ -0,0 +1,68 @@ + 'decimal:2', + 'base_amount' => 'decimal:2', + 'partner_rate' => 'decimal:2', + 'manager_rate' => 'decimal:2', + 'partner_commission' => 'decimal:2', + 'manager_commission' => 'decimal:2', + ]; + + /** + * 수수료 정산 관계 + */ + public function commission(): BelongsTo + { + return $this->belongsTo(SalesCommission::class, 'commission_id'); + } + + /** + * 계약 상품 관계 + */ + public function contractProduct(): BelongsTo + { + return $this->belongsTo(SalesContractProduct::class, 'contract_product_id'); + } + + /** + * 총 수당액 Accessor + */ + public function getTotalCommissionAttribute(): float + { + return $this->partner_commission + $this->manager_commission; + } +} diff --git a/app/Models/Sales/SalesConsultation.php b/app/Models/Sales/SalesConsultation.php new file mode 100644 index 00000000..0f141e40 --- /dev/null +++ b/app/Models/Sales/SalesConsultation.php @@ -0,0 +1,261 @@ + '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(), + ]); + } + + /** + * 음성 상담 기록 생성 + * + * @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, + string $scenarioType, + ?int $stepId, + string $filePath, + string $fileName, + int $fileSize, + ?string $transcript = null, + ?int $duration = null, + ?string $gcsUri = 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, + 'gcs_uri' => $gcsUri, + '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/SalesContractProduct.php b/app/Models/Sales/SalesContractProduct.php new file mode 100644 index 00000000..0a49b66c --- /dev/null +++ b/app/Models/Sales/SalesContractProduct.php @@ -0,0 +1,106 @@ + 'integer', + 'management_id' => 'integer', + 'category_id' => 'integer', + 'product_id' => 'integer', + 'registration_fee' => 'decimal:2', + 'subscription_fee' => 'decimal:2', + 'discount_rate' => 'decimal:2', + 'created_by' => 'integer', + ]; + + /** + * 테넌트 관계 + */ + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + /** + * 영업관리 관계 + */ + public function management(): BelongsTo + { + return $this->belongsTo(SalesTenantManagement::class, 'management_id'); + } + + /** + * 카테고리 관계 + */ + public function category(): BelongsTo + { + return $this->belongsTo(SalesProductCategory::class, 'category_id'); + } + + /** + * 상품 관계 + */ + public function product(): BelongsTo + { + return $this->belongsTo(SalesProduct::class, 'product_id'); + } + + /** + * 등록자 관계 + */ + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + /** + * 테넌트별 총 가입비 + */ + public static function getTotalRegistrationFee(int $tenantId): float + { + return self::where('tenant_id', $tenantId)->sum('registration_fee') ?? 0; + } + + /** + * 테넌트별 총 구독료 + */ + public static function getTotalSubscriptionFee(int $tenantId): float + { + return self::where('tenant_id', $tenantId)->sum('subscription_fee') ?? 0; + } +} diff --git a/app/Models/Sales/SalesManagerDocument.php b/app/Models/Sales/SalesManagerDocument.php index 6897f351..40f32da3 100644 --- a/app/Models/Sales/SalesManagerDocument.php +++ b/app/Models/Sales/SalesManagerDocument.php @@ -37,17 +37,15 @@ class SalesManagerDocument extends Model /** * 문서 타입 목록 + * - 계약서는 모두의싸인을 통해 별도 처리 */ public const DOCUMENT_TYPES = [ - 'id_card' => '신분증', - 'business_license' => '사업자등록증', - 'contract' => '계약서', + 'resident_copy' => '등본사본', 'bank_account' => '통장사본', - 'other' => '기타', ]; /** - * 사용자 (영업담당자) + * 사용자 (영업파트너) */ public function user(): BelongsTo { 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/SalesProduct.php b/app/Models/Sales/SalesProduct.php new file mode 100644 index 00000000..4e8643bd --- /dev/null +++ b/app/Models/Sales/SalesProduct.php @@ -0,0 +1,125 @@ + 'integer', + 'development_fee' => 'decimal:2', + 'registration_fee' => 'decimal:2', + 'subscription_fee' => 'decimal:2', + 'partner_commission_rate' => 'decimal:2', + 'manager_commission_rate' => 'decimal:2', + 'allow_flexible_pricing' => 'boolean', + 'is_required' => 'boolean', + 'display_order' => 'integer', + 'is_active' => 'boolean', + ]; + + /** + * 카테고리 관계 + */ + public function category(): BelongsTo + { + return $this->belongsTo(SalesProductCategory::class, 'category_id'); + } + + /** + * 활성 상품 스코프 + */ + public function scopeActive($query) + { + return $query->where('is_active', true); + } + + /** + * 정렬 스코프 + */ + public function scopeOrdered($query) + { + return $query->orderBy('display_order')->orderBy('name'); + } + + /** + * 총 수당율 + */ + public function getTotalCommissionRateAttribute(): float + { + return $this->partner_commission_rate + $this->manager_commission_rate; + } + + /** + * 수당 계산 (개발비 기준) + */ + public function getCommissionAttribute(): float + { + return $this->development_fee * ($this->total_commission_rate / 100); + } + + /** + * 포맷된 개발비 + */ + public function getFormattedDevelopmentFeeAttribute(): string + { + return '₩' . number_format($this->development_fee); + } + + /** + * 포맷된 가입비 + */ + public function getFormattedRegistrationFeeAttribute(): string + { + return '₩' . number_format($this->registration_fee); + } + + /** + * 포맷된 구독료 + */ + public function getFormattedSubscriptionFeeAttribute(): string + { + return '₩' . number_format($this->subscription_fee); + } +} diff --git a/app/Models/Sales/SalesProductCategory.php b/app/Models/Sales/SalesProductCategory.php new file mode 100644 index 00000000..ba8cd2ce --- /dev/null +++ b/app/Models/Sales/SalesProductCategory.php @@ -0,0 +1,71 @@ + 'integer', + 'is_active' => 'boolean', + ]; + + /** + * 상품 관계 + */ + public function products(): HasMany + { + return $this->hasMany(SalesProduct::class, 'category_id'); + } + + /** + * 활성 상품만 + */ + public function activeProducts(): HasMany + { + return $this->products()->where('is_active', true)->orderBy('display_order'); + } + + /** + * 활성 카테고리 스코프 + */ + public function scopeActive($query) + { + return $query->where('is_active', true); + } + + /** + * 정렬 스코프 + */ + public function scopeOrdered($query) + { + return $query->orderBy('display_order')->orderBy('name'); + } +} diff --git a/app/Models/Sales/SalesScenarioChecklist.php b/app/Models/Sales/SalesScenarioChecklist.php new file mode 100644 index 00000000..65d261a2 --- /dev/null +++ b/app/Models/Sales/SalesScenarioChecklist.php @@ -0,0 +1,234 @@ + '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 + { + $currentUserId = $userId ?? auth()->id(); + + $checklist = self::firstOrNew([ + 'tenant_id' => $tenantId, + 'scenario_type' => $scenarioType, + 'step_id' => $stepId, + 'checkpoint_id' => $checkpointId, + ]); + + // 새 레코드인 경우 필수 필드 설정 + if (!$checklist->exists) { + $checklist->user_id = $currentUserId; + $checklist->checkpoint_index = 0; // 기본값 + } + + $checklist->is_checked = $checked; + $checklist->checked_at = $checked ? now() : null; + $checklist->checked_by = $checked ? $currentUserId : 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); + } + + /** + * 간단한 진행률 계산 (전체 체크포인트 수 기준) + * + * @param int $tenantId + * @param string $scenarioType 'sales' 또는 'manager' + * @return array ['completed' => 완료 수, 'total' => 전체 수, 'percentage' => 백분율] + */ + public static function getSimpleProgress(int $tenantId, string $scenarioType): array + { + // 전체 체크포인트 수 (config에서 계산) + $configKey = $scenarioType === 'sales' ? 'sales_scenario.sales_steps' : 'sales_scenario.manager_steps'; + $steps = config($configKey, []); + + $total = 0; + $validCheckpointKeys = []; + + // config에 정의된 유효한 체크포인트만 수집 + foreach ($steps as $step) { + foreach ($step['checkpoints'] ?? [] as $checkpoint) { + $total++; + $validCheckpointKeys[] = "{$step['id']}_{$checkpoint['id']}"; + } + } + + // 완료된 체크포인트 수 (config에 존재하는 것만 카운트) + $checkedItems = self::where('tenant_id', $tenantId) + ->where('scenario_type', $scenarioType) + ->where('is_checked', true) + ->get(); + + $completed = 0; + foreach ($checkedItems as $item) { + $key = "{$item->step_id}_{$item->checkpoint_id}"; + if (in_array($key, $validCheckpointKeys)) { + $completed++; + } + } + + $percentage = $total > 0 ? round(($completed / $total) * 100) : 0; + + return [ + 'completed' => $completed, + 'total' => $total, + 'percentage' => $percentage, + ]; + } + + /** + * 테넌트별 영업/매니저 진행률 한번에 조회 + */ + public static function getTenantProgress(int $tenantId): array + { + return [ + 'sales' => self::getSimpleProgress($tenantId, 'sales'), + 'manager' => self::getSimpleProgress($tenantId, 'manager'), + ]; + } +} diff --git a/app/Models/Sales/SalesTenantManagement.php b/app/Models/Sales/SalesTenantManagement.php new file mode 100644 index 00000000..377c21dd --- /dev/null +++ b/app/Models/Sales/SalesTenantManagement.php @@ -0,0 +1,323 @@ + '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', + // 입금 정보 + 'deposit_amount' => 'decimal:2', + 'deposit_paid_date' => 'date', + 'balance_amount' => 'decimal:2', + 'balance_paid_date' => 'date', + 'total_registration_fee' => 'decimal:2', + ]; + + /** + * 상태 상수 + */ + 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 => '이탈', + ]; + + /** + * 본사 진행 상태 상수 + */ + const HQ_STATUS_PENDING = 'pending'; // 대기 + const HQ_STATUS_REVIEW = 'review'; // 검토 + const HQ_STATUS_PLANNING = 'planning'; // 기획안작성 + const HQ_STATUS_CODING = 'coding'; // 개발코드작성 + const HQ_STATUS_DEV_TEST = 'dev_test'; // 개발테스트 + const HQ_STATUS_DEV_DONE = 'dev_done'; // 개발완료 + const HQ_STATUS_INT_TEST = 'int_test'; // 통합테스트 + const HQ_STATUS_HANDOVER = 'handover'; // 인계 + + /** + * 본사 진행 상태 라벨 + */ + public static array $hqStatusLabels = [ + self::HQ_STATUS_PENDING => '대기', + self::HQ_STATUS_REVIEW => '검토', + self::HQ_STATUS_PLANNING => '기획안작성', + self::HQ_STATUS_CODING => '개발코드작성', + self::HQ_STATUS_DEV_TEST => '개발테스트', + self::HQ_STATUS_DEV_DONE => '개발완료', + self::HQ_STATUS_INT_TEST => '통합테스트', + self::HQ_STATUS_HANDOVER => '인계', + ]; + + /** + * 본사 진행 상태 순서 (프로그레스바용) + */ + public static array $hqStatusOrder = [ + self::HQ_STATUS_PENDING => 0, + self::HQ_STATUS_REVIEW => 1, + self::HQ_STATUS_PLANNING => 2, + self::HQ_STATUS_CODING => 3, + self::HQ_STATUS_DEV_TEST => 4, + self::HQ_STATUS_DEV_DONE => 5, + self::HQ_STATUS_INT_TEST => 6, + self::HQ_STATUS_HANDOVER => 7, + ]; + + /** + * 수당 지급 상태 상수 + */ + const INCENTIVE_PENDING = 'pending'; // 대기 + const INCENTIVE_ELIGIBLE = 'eligible'; // 지급대상 + const INCENTIVE_PAID = 'paid'; // 지급완료 + + /** + * 수당 지급 상태 라벨 + */ + public static array $incentiveStatusLabels = [ + self::INCENTIVE_PENDING => '대기', + self::INCENTIVE_ELIGIBLE => '지급대상', + self::INCENTIVE_PAID => '지급완료', + ]; + + /** + * 테넌트 관계 + */ + 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'); + } + + /** + * 수수료 정산 관계 + */ + public function commissions(): HasMany + { + return $this->hasMany(SalesCommission::class, 'management_id'); + } + + /** + * 계약 상품 관계 + */ + public function contractProducts(): HasMany + { + return $this->hasMany(SalesContractProduct::class, 'management_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; + } + + /** + * 본사 진행 상태 라벨 Accessor + */ + public function getHqStatusLabelAttribute(): string + { + return self::$hqStatusLabels[$this->hq_status ?? self::HQ_STATUS_PENDING] ?? '대기'; + } + + /** + * 본사 진행 상태 순서 (0-7) + */ + public function getHqStatusStepAttribute(): int + { + return self::$hqStatusOrder[$this->hq_status ?? self::HQ_STATUS_PENDING] ?? 0; + } + + /** + * 수당 지급 상태 라벨 Accessor + */ + public function getIncentiveStatusLabelAttribute(): string + { + return self::$incentiveStatusLabels[$this->incentive_status ?? self::INCENTIVE_PENDING] ?? '대기'; + } + + /** + * 본사 진행 가능 여부 (매니저 100% 완료 시) + */ + public function isHqProgressEnabled(): bool + { + return $this->manager_progress >= 100; + } + + /** + * 특정 상태 스코프 + */ + 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/Sales/TenantProspect.php b/app/Models/Sales/TenantProspect.php index 2a26c052..f196a160 100644 --- a/app/Models/Sales/TenantProspect.php +++ b/app/Models/Sales/TenantProspect.php @@ -23,7 +23,7 @@ class TenantProspect extends Model public const STATUS_CONVERTED = 'converted'; // 테넌트 전환 완료 public const VALIDITY_MONTHS = 2; // 영업권 유효기간 (개월) - public const COOLDOWN_MONTHS = 1; // 쿨다운 기간 (개월) + public const COOLDOWN_MONTHS = 1; // 재등록 대기 기간 (개월) protected $fillable = [ 'business_number', @@ -102,7 +102,7 @@ public function isConverted(): bool } /** - * 쿨다운 중 여부 + * 재등록 대기 중 여부 */ public function isInCooldown(): bool { @@ -131,7 +131,7 @@ public function getStatusLabelAttribute(): string } if ($this->isInCooldown()) { - return '쿨다운'; + return '대기중'; } return '만료'; 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/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/Models/User.php b/app/Models/User.php index 495c51ac..3780779a 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -72,7 +72,7 @@ protected function casts(): array } /** - * 상위 관리자 (영업담당자 계층 구조) + * 상위 관리자 (영업파트너 계층 구조) */ public function parent(): \Illuminate\Database\Eloquent\Relations\BelongsTo { @@ -96,7 +96,7 @@ public function approver(): \Illuminate\Database\Eloquent\Relations\BelongsTo } /** - * 영업담당자 첨부 서류 + * 영업파트너 첨부 서류 */ public function salesDocuments(): HasMany { diff --git a/app/Services/GoogleCloudStorageService.php b/app/Services/GoogleCloudStorageService.php new file mode 100644 index 00000000..c00e30b2 --- /dev/null +++ b/app/Services/GoogleCloudStorageService.php @@ -0,0 +1,307 @@ +loadConfig(); + } + + /** + * GCS 설정 로드 + * + * 우선순위: + * 1. DB 설정 (ai_configs 테이블의 활성화된 gcs provider) + * 2. 환경변수 (.env의 GCS_BUCKET_NAME, GCS_SERVICE_ACCOUNT_PATH) + * 3. 레거시 파일 설정 (/sales/apikey/) + */ + private function loadConfig(): void + { + // 1. DB 설정 확인 (GCS_USE_DB_CONFIG=true일 때만) + if (config('gcs.use_db_config', true)) { + $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->bucketName && $this->serviceAccount) { + $this->configSource = 'db'; + Log::debug('GCS 설정 로드: DB (활성화된 설정: ' . $dbConfig->name . ')'); + return; + } + } + } + + // 2. 환경변수 (.env) 설정 + $envBucket = config('gcs.bucket_name'); + $envServiceAccountPath = config('gcs.service_account_path'); + + if ($envBucket && $envServiceAccountPath && file_exists($envServiceAccountPath)) { + $this->bucketName = $envBucket; + $this->serviceAccount = json_decode(file_get_contents($envServiceAccountPath), true); + + if ($this->serviceAccount) { + $this->configSource = 'env'; + Log::debug('GCS 설정 로드: 환경변수 (.env)'); + return; + } + } + + // 3. 레거시 파일 설정 (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) { + $this->configSource = 'legacy'; + Log::debug('GCS 설정 로드: 레거시 파일'); + } + } + + /** + * 현재 설정 소스 반환 + */ + public function getConfigSource(): string + { + return $this->configSource; + } + + /** + * 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/app/Services/Sales/SalesManagerService.php b/app/Services/Sales/SalesManagerService.php index 96733175..c7a5a36e 100644 --- a/app/Services/Sales/SalesManagerService.php +++ b/app/Services/Sales/SalesManagerService.php @@ -2,6 +2,7 @@ namespace App\Services\Sales; +use App\Models\Department; use App\Models\DepartmentUser; use App\Models\Role; use App\Models\Sales\SalesManagerDocument; @@ -53,7 +54,10 @@ public function createSalesPartner(array $data, array $documents = []): User $this->syncRoles($user, $tenantId, $data['role_ids']); } - // 4. 첨부 서류 저장 + // 4. 영업팀 부서 자동 할당 + $this->assignSalesDepartment($user, $tenantId); + + // 5. 첨부 서류 저장 if (!empty($documents)) { $this->uploadDocuments($user, $tenantId, $documents); } @@ -229,6 +233,41 @@ public function removeRole(User $user, string $roleName): bool 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(), + ] + ); + } + /** * 역할 동기화 */ diff --git a/app/Services/Sales/TenantProspectService.php b/app/Services/Sales/TenantProspectService.php index 9ee9e762..a4a0c021 100644 --- a/app/Services/Sales/TenantProspectService.php +++ b/app/Services/Sales/TenantProspectService.php @@ -14,17 +14,19 @@ class TenantProspectService /** * 명함 등록 (영업권 확보) */ - public function register(array $data, ?UploadedFile $businessCard = null): TenantProspect + public function register(array $data, ?UploadedFile $businessCard = null, ?string $businessCardBase64 = null): TenantProspect { - return DB::transaction(function () use ($data, $businessCard) { + return DB::transaction(function () use ($data, $businessCard, $businessCardBase64) { $now = now(); $expiresAt = $now->copy()->addMonths(TenantProspect::VALIDITY_MONTHS); $cooldownEndsAt = $expiresAt->copy()->addMonths(TenantProspect::COOLDOWN_MONTHS); - // 명함 이미지 저장 + // 명함 이미지 저장 (파일 업로드 또는 Base64) $businessCardPath = null; if ($businessCard) { $businessCardPath = $this->uploadBusinessCard($businessCard, $data['registered_by']); + } elseif ($businessCardBase64) { + $businessCardPath = $this->saveBase64Image($businessCardBase64, $data['registered_by']); } return TenantProspect::create([ @@ -101,17 +103,27 @@ public function update( public function convertToTenant(TenantProspect $prospect, int $convertedBy): Tenant { return DB::transaction(function () use ($prospect, $convertedBy) { + // 고유 테넌트 코드 생성 (T + 타임스탬프 + 랜덤) + $tenantCode = 'T' . now()->format('ymd') . strtoupper(substr(uniqid(), -4)); + // 테넌트 생성 $tenant = Tenant::create([ 'company_name' => $prospect->company_name, + 'code' => $tenantCode, '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, + 'tenant_type' => 'STD', // STD = Standard (일반 고객) + ]); + + // 전환한 사용자를 테넌트에 연결 (user_tenants) + $tenant->users()->attach($convertedBy, [ + 'is_active' => true, + 'is_default' => false, + 'joined_at' => now(), ]); // 영업권 상태 업데이트 @@ -167,7 +179,7 @@ public function canRegister(string $businessNumber, ?int $excludeId = null): arr ]; } - // 쿨다운 중인 경우 + // 재등록 대기 중인 경우 $inCooldown = (clone $query) ->where('status', TenantProspect::STATUS_EXPIRED) ->where('cooldown_ends_at', '>', now()) @@ -176,7 +188,7 @@ public function canRegister(string $businessNumber, ?int $excludeId = null): arr if ($inCooldown) { return [ 'can_register' => false, - 'reason' => "쿨다운 기간 중입니다. (등록 가능: {$inCooldown->cooldown_ends_at->format('Y-m-d')})", + 'reason' => "재등록 대기 기간 중입니다. (등록 가능: {$inCooldown->cooldown_ends_at->format('Y-m-d')})", 'prospect' => $inCooldown, ]; } @@ -265,6 +277,32 @@ private function uploadBusinessCard(UploadedFile $file, int $userId): string return $this->uploadAttachment($file, $userId); } + /** + * Base64 이미지 저장 + */ + private function saveBase64Image(string $base64Data, int $userId): ?string + { + // data:image/jpeg;base64,... 형식에서 데이터 추출 + if (preg_match('/^data:image\/(\w+);base64,/', $base64Data, $matches)) { + $extension = $matches[1]; + $base64Data = preg_replace('/^data:image\/\w+;base64,/', '', $base64Data); + } else { + $extension = 'jpg'; + } + + $imageData = base64_decode($base64Data); + if ($imageData === false) { + return null; + } + + $storedName = Str::uuid() . '.' . $extension; + $filePath = "prospects/{$userId}/{$storedName}"; + + Storage::disk('tenant')->put($filePath, $imageData); + + return $filePath; + } + /** * 명함 이미지 삭제 */ diff --git a/app/Services/SalesCommissionService.php b/app/Services/SalesCommissionService.php new file mode 100644 index 00000000..596d0e10 --- /dev/null +++ b/app/Services/SalesCommissionService.php @@ -0,0 +1,449 @@ +with(['tenant', 'partner.user', 'manager', 'management']); + + // 상태 필터 + if (!empty($filters['status'])) { + $query->where('status', $filters['status']); + } + + // 입금구분 필터 + if (!empty($filters['payment_type'])) { + $query->where('payment_type', $filters['payment_type']); + } + + // 영업파트너 필터 + if (!empty($filters['partner_id'])) { + $query->where('partner_id', $filters['partner_id']); + } + + // 매니저 필터 + if (!empty($filters['manager_user_id'])) { + $query->where('manager_user_id', $filters['manager_user_id']); + } + + // 지급예정 년/월 필터 + if (!empty($filters['scheduled_year']) && !empty($filters['scheduled_month'])) { + $query->forScheduledMonth((int) $filters['scheduled_year'], (int) $filters['scheduled_month']); + } + + // 입금일 기간 필터 + if (!empty($filters['payment_start_date']) && !empty($filters['payment_end_date'])) { + $query->paymentDateBetween($filters['payment_start_date'], $filters['payment_end_date']); + } + + // 테넌트 검색 + if (!empty($filters['search'])) { + $search = $filters['search']; + $query->whereHas('tenant', function ($q) use ($search) { + $q->where('name', 'like', "%{$search}%") + ->orWhere('company_name', 'like', "%{$search}%"); + }); + } + + return $query + ->orderBy('scheduled_payment_date', 'desc') + ->orderBy('created_at', 'desc') + ->paginate($perPage); + } + + /** + * 정산 상세 조회 + */ + public function getCommissionById(int $id): ?SalesCommission + { + return SalesCommission::with([ + 'tenant', + 'partner.user', + 'manager', + 'management', + 'details.contractProduct.product', + 'approver', + ])->find($id); + } + + // ========================================================================= + // 수당 생성 (입금 시) + // ========================================================================= + + /** + * 입금 등록 및 수당 생성 + */ + public function createCommission(int $managementId, string $paymentType, float $paymentAmount, string $paymentDate): SalesCommission + { + return DB::transaction(function () use ($managementId, $paymentType, $paymentAmount, $paymentDate) { + $management = SalesTenantManagement::with(['salesPartner', 'contractProducts.product']) + ->findOrFail($managementId); + + // 영업파트너 필수 체크 + if (!$management->sales_partner_id) { + throw new \Exception('영업파트너가 지정되지 않았습니다.'); + } + + $partner = $management->salesPartner; + $paymentDateCarbon = Carbon::parse($paymentDate); + + // 계약 상품이 없으면 기본 계산 + $contractProducts = $management->contractProducts; + $totalRegistrationFee = $contractProducts->sum('registration_fee') ?: $paymentAmount * 2; + $baseAmount = $totalRegistrationFee / 2; // 가입비의 50% + + // 수당률 (영업파트너 설정 또는 기본값) + $partnerRate = $partner->commission_rate ?? self::DEFAULT_PARTNER_RATE; + $managerRate = $partner->manager_commission_rate ?? self::DEFAULT_MANAGER_RATE; + + // 수당 계산 + $partnerCommission = $baseAmount * ($partnerRate / 100); + $managerCommission = $management->manager_user_id + ? $baseAmount * ($managerRate / 100) + : 0; + + // 지급예정일 (익월 10일) + $scheduledPaymentDate = SalesCommission::calculateScheduledPaymentDate($paymentDateCarbon); + + // 정산 생성 + $commission = SalesCommission::create([ + 'tenant_id' => $management->tenant_id, + 'management_id' => $managementId, + 'payment_type' => $paymentType, + 'payment_amount' => $paymentAmount, + 'payment_date' => $paymentDate, + 'base_amount' => $baseAmount, + 'partner_rate' => $partnerRate, + 'manager_rate' => $managerRate, + 'partner_commission' => $partnerCommission, + 'manager_commission' => $managerCommission, + 'scheduled_payment_date' => $scheduledPaymentDate, + 'status' => SalesCommission::STATUS_PENDING, + 'partner_id' => $partner->id, + 'manager_user_id' => $management->manager_user_id, + ]); + + // 상품별 상세 내역 생성 + foreach ($contractProducts as $contractProduct) { + $productBaseAmount = ($contractProduct->registration_fee ?? 0) / 2; + $productPartnerRate = $contractProduct->product->partner_commission ?? $partnerRate; + $productManagerRate = $contractProduct->product->manager_commission ?? $managerRate; + + SalesCommissionDetail::create([ + 'commission_id' => $commission->id, + 'contract_product_id' => $contractProduct->id, + 'registration_fee' => $contractProduct->registration_fee ?? 0, + 'base_amount' => $productBaseAmount, + 'partner_rate' => $productPartnerRate, + 'manager_rate' => $productManagerRate, + 'partner_commission' => $productBaseAmount * ($productPartnerRate / 100), + 'manager_commission' => $productBaseAmount * ($productManagerRate / 100), + ]); + } + + // management 입금 정보 업데이트 + $updateData = []; + if ($paymentType === SalesCommission::PAYMENT_DEPOSIT) { + $updateData = [ + 'deposit_amount' => $paymentAmount, + 'deposit_paid_date' => $paymentDate, + 'deposit_status' => 'paid', + ]; + } else { + $updateData = [ + 'balance_amount' => $paymentAmount, + 'balance_paid_date' => $paymentDate, + 'balance_status' => 'paid', + ]; + } + + // 총 가입비 업데이트 + $updateData['total_registration_fee'] = $totalRegistrationFee; + $management->update($updateData); + + return $commission->load(['tenant', 'partner.user', 'manager', 'details']); + }); + } + + // ========================================================================= + // 승인/지급 처리 + // ========================================================================= + + /** + * 승인 처리 + */ + public function approve(int $commissionId, int $approverId): SalesCommission + { + $commission = SalesCommission::findOrFail($commissionId); + + if (!$commission->approve($approverId)) { + throw new \Exception('승인할 수 없는 상태입니다.'); + } + + return $commission->fresh(['tenant', 'partner.user', 'manager']); + } + + /** + * 일괄 승인 + */ + public function bulkApprove(array $ids, int $approverId): int + { + $count = 0; + + DB::transaction(function () use ($ids, $approverId, &$count) { + $commissions = SalesCommission::whereIn('id', $ids) + ->where('status', SalesCommission::STATUS_PENDING) + ->get(); + + foreach ($commissions as $commission) { + if ($commission->approve($approverId)) { + $count++; + } + } + }); + + return $count; + } + + /** + * 지급완료 처리 + */ + public function markAsPaid(int $commissionId, ?string $bankReference = null): SalesCommission + { + $commission = SalesCommission::findOrFail($commissionId); + + if (!$commission->markAsPaid($bankReference)) { + throw new \Exception('지급완료 처리할 수 없는 상태입니다.'); + } + + // 영업파트너 누적 수당 업데이트 + $this->updatePartnerTotalCommission($commission->partner_id); + + return $commission->fresh(['tenant', 'partner.user', 'manager']); + } + + /** + * 일괄 지급완료 + */ + public function bulkMarkAsPaid(array $ids, ?string $bankReference = null): int + { + $count = 0; + $partnerIds = []; + + DB::transaction(function () use ($ids, $bankReference, &$count, &$partnerIds) { + $commissions = SalesCommission::whereIn('id', $ids) + ->where('status', SalesCommission::STATUS_APPROVED) + ->get(); + + foreach ($commissions as $commission) { + if ($commission->markAsPaid($bankReference)) { + $count++; + $partnerIds[] = $commission->partner_id; + } + } + }); + + // 영업파트너 누적 수당 일괄 업데이트 + foreach (array_unique($partnerIds) as $partnerId) { + $this->updatePartnerTotalCommission($partnerId); + } + + return $count; + } + + /** + * 취소 처리 + */ + public function cancel(int $commissionId): SalesCommission + { + $commission = SalesCommission::findOrFail($commissionId); + + if (!$commission->cancel()) { + throw new \Exception('취소할 수 없는 상태입니다.'); + } + + return $commission->fresh(['tenant', 'partner.user', 'manager']); + } + + // ========================================================================= + // 영업파트너/매니저 대시보드용 + // ========================================================================= + + /** + * 영업파트너 수당 요약 + */ + public function getPartnerCommissionSummary(int $partnerId): array + { + $commissions = SalesCommission::forPartner($partnerId)->get(); + + $thisMonth = now()->format('Y-m'); + $thisMonthStart = now()->startOfMonth()->format('Y-m-d'); + $thisMonthEnd = now()->endOfMonth()->format('Y-m-d'); + + return [ + // 이번 달 지급예정 (승인 완료된 건) + 'scheduled_this_month' => $commissions + ->where('status', SalesCommission::STATUS_APPROVED) + ->filter(fn($c) => $c->scheduled_payment_date->format('Y-m') === $thisMonth) + ->sum('partner_commission'), + + // 누적 수령 수당 + 'total_received' => $commissions + ->where('status', SalesCommission::STATUS_PAID) + ->sum('partner_commission'), + + // 대기중 수당 + 'pending_amount' => $commissions + ->where('status', SalesCommission::STATUS_PENDING) + ->sum('partner_commission'), + + // 이번 달 신규 계약 건수 + 'contracts_this_month' => $commissions + ->filter(fn($c) => $c->payment_date >= $thisMonthStart && $c->payment_date <= $thisMonthEnd) + ->count(), + ]; + } + + /** + * 매니저 수당 요약 + */ + public function getManagerCommissionSummary(int $managerUserId): array + { + $commissions = SalesCommission::forManager($managerUserId)->get(); + + $thisMonth = now()->format('Y-m'); + + return [ + // 이번 달 지급예정 (승인 완료된 건) + 'scheduled_this_month' => $commissions + ->where('status', SalesCommission::STATUS_APPROVED) + ->filter(fn($c) => $c->scheduled_payment_date->format('Y-m') === $thisMonth) + ->sum('manager_commission'), + + // 누적 수령 수당 + 'total_received' => $commissions + ->where('status', SalesCommission::STATUS_PAID) + ->sum('manager_commission'), + + // 대기중 수당 + 'pending_amount' => $commissions + ->where('status', SalesCommission::STATUS_PENDING) + ->sum('manager_commission'), + ]; + } + + /** + * 최근 수당 내역 (대시보드용) + */ + public function getRecentCommissions(int $partnerId, int $limit = 5): Collection + { + return SalesCommission::forPartner($partnerId) + ->with(['tenant', 'management']) + ->orderBy('created_at', 'desc') + ->limit($limit) + ->get(); + } + + // ========================================================================= + // 통계 + // ========================================================================= + + /** + * 정산 통계 (본사 대시보드용) + */ + public function getSettlementStats(int $year, int $month): array + { + $commissions = SalesCommission::forScheduledMonth($year, $month)->get(); + + return [ + // 상태별 건수 및 금액 + 'pending' => [ + 'count' => $commissions->where('status', SalesCommission::STATUS_PENDING)->count(), + 'partner_total' => $commissions->where('status', SalesCommission::STATUS_PENDING)->sum('partner_commission'), + 'manager_total' => $commissions->where('status', SalesCommission::STATUS_PENDING)->sum('manager_commission'), + ], + 'approved' => [ + 'count' => $commissions->where('status', SalesCommission::STATUS_APPROVED)->count(), + 'partner_total' => $commissions->where('status', SalesCommission::STATUS_APPROVED)->sum('partner_commission'), + 'manager_total' => $commissions->where('status', SalesCommission::STATUS_APPROVED)->sum('manager_commission'), + ], + 'paid' => [ + 'count' => $commissions->where('status', SalesCommission::STATUS_PAID)->count(), + 'partner_total' => $commissions->where('status', SalesCommission::STATUS_PAID)->sum('partner_commission'), + 'manager_total' => $commissions->where('status', SalesCommission::STATUS_PAID)->sum('manager_commission'), + ], + + // 전체 합계 + 'total' => [ + 'count' => $commissions->count(), + 'base_amount' => $commissions->sum('base_amount'), + 'partner_commission' => $commissions->sum('partner_commission'), + 'manager_commission' => $commissions->sum('manager_commission'), + ], + ]; + } + + /** + * 입금 대기 중인 테넌트 목록 + */ + public function getPendingPaymentTenants(): Collection + { + return SalesTenantManagement::with(['tenant', 'salesPartner.user', 'manager']) + ->contracted() + ->where(function ($query) { + $query->where('deposit_status', 'pending') + ->orWhere('balance_status', 'pending'); + }) + ->orderBy('contracted_at', 'desc') + ->get(); + } + + // ========================================================================= + // 내부 메서드 + // ========================================================================= + + /** + * 영업파트너 누적 수당 업데이트 + */ + private function updatePartnerTotalCommission(int $partnerId): void + { + $totalPaid = SalesCommission::forPartner($partnerId) + ->paid() + ->sum('partner_commission'); + + $contractCount = SalesCommission::forPartner($partnerId) + ->paid() + ->count(); + + SalesPartner::where('id', $partnerId)->update([ + 'total_commission' => $totalPaid, + 'total_contracts' => $contractCount, + ]); + } +} diff --git a/app/Services/UserService.php b/app/Services/UserService.php index f24ddce1..dda17f51 100644 --- a/app/Services/UserService.php +++ b/app/Services/UserService.php @@ -324,6 +324,21 @@ public function forceDeleteUser(int $id): bool // 2. 관련 데이터 삭제 $user->tenants()->detach(); // user_tenants 관계 삭제 + // 2-1. user_roles 영구 삭제 (외래 키 제약 때문에 forceDelete 필요) + DB::table('user_roles')->where('user_id', $user->id)->delete(); + + // 2-2. department_user 영구 삭제 + DB::table('department_user')->where('user_id', $user->id)->delete(); + + // 2-3. sales_partners 삭제 (영업파트너) + DB::table('sales_partners')->where('user_id', $user->id)->delete(); + + // 2-4. sales_manager_documents 삭제 (영업파트너 서류) + DB::table('sales_manager_documents')->where('user_id', $user->id)->delete(); + + // 2-5. 하위 사용자의 parent_id 해제 + User::where('parent_id', $user->id)->update(['parent_id' => null]); + // 3. 사용자 영구 삭제 return $user->forceDelete(); }); diff --git a/claudedocs/ai-config-설정.md b/claudedocs/ai-config-설정.md new file mode 100644 index 00000000..de03371f --- /dev/null +++ b/claudedocs/ai-config-설정.md @@ -0,0 +1,325 @@ +# AI 및 스토리지 설정 기술문서 + +> 최종 업데이트: 2026-01-29 + +## 개요 + +SAM MNG 시스템의 AI API 및 클라우드 스토리지(GCS) 설정을 관리하는 기능입니다. +관리자 UI에서 설정하거나, `.env` 환경변수로 설정할 수 있습니다. + +**접근 경로**: 시스템 관리 > AI 설정 (`/system/ai-config`) + +--- + +## 지원 Provider + +### AI Provider +| Provider | 용도 | 기본 모델 | +|----------|------|----------| +| `gemini` | Google Gemini (명함 OCR, AI 어시스턴트) | gemini-2.0-flash | +| `claude` | Anthropic Claude | claude-sonnet-4-20250514 | +| `openai` | OpenAI GPT | gpt-4o | + +### Storage Provider +| Provider | 용도 | +|----------|------| +| `gcs` | Google Cloud Storage (음성 녹음 백업) | + +--- + +## 데이터베이스 구조 + +### 테이블: `ai_configs` + +```sql +CREATE TABLE ai_configs ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(50) NOT NULL, -- 설정 이름 (예: "Production Gemini") + provider VARCHAR(20) NOT NULL, -- gemini, claude, openai, gcs + api_key VARCHAR(255) NOT NULL, -- API 키 (GCS는 'gcs_service_account') + model VARCHAR(100) NOT NULL, -- 모델명 (GCS는 '-') + base_url VARCHAR(255) NULL, -- 커스텀 Base URL + description TEXT NULL, -- 설명 + is_active BOOLEAN DEFAULT FALSE, -- 활성화 여부 (provider당 1개만) + options JSON NULL, -- 추가 옵션 (아래 참조) + created_at TIMESTAMP, + updated_at TIMESTAMP, + deleted_at TIMESTAMP NULL -- Soft Delete +); +``` + +### options JSON 구조 + +**AI Provider (Gemini Vertex AI)**: +```json +{ + "auth_type": "vertex_ai", + "project_id": "my-gcp-project", + "region": "us-central1", + "service_account_path": "/var/www/sales/apikey/google_service_account.json" +} +``` + +**AI Provider (API Key)**: +```json +{ + "auth_type": "api_key" +} +``` + +**GCS Provider**: +```json +{ + "bucket_name": "my-bucket-name", + "service_account_path": "/var/www/sales/apikey/google_service_account.json", + "service_account_json": { ... } // 또는 JSON 직접 입력 +} +``` + +--- + +## 설정 우선순위 + +### GCS 설정 우선순위 + +``` +1. DB 설정 (ai_configs 테이블의 활성화된 gcs provider) + ↓ 없으면 +2. 환경변수 (.env의 GCS_BUCKET_NAME, GCS_SERVICE_ACCOUNT_PATH) + ↓ 없으면 +3. 레거시 파일 (/sales/apikey/gcs_config.txt, google_service_account.json) +``` + +### AI 설정 우선순위 + +``` +1. DB 설정 (ai_configs 테이블의 활성화된 provider) + ↓ 없으면 +2. 환경변수 (.env의 GEMINI_API_KEY 등) + ↓ 없으면 +3. 레거시 파일 +``` + +--- + +## 환경변수 설정 (.env) + +### GCS 설정 +```env +# Google Cloud Storage (음성 녹음 백업) +GCS_BUCKET_NAME=your-bucket-name +GCS_SERVICE_ACCOUNT_PATH=/var/www/sales/apikey/google_service_account.json +GCS_USE_DB_CONFIG=true # false면 DB 설정 무시, .env만 사용 +``` + +### AI 설정 (참고) +```env +# Google Gemini API +GEMINI_API_KEY=your-api-key +GEMINI_PROJECT_ID=your-project-id +``` + +--- + +## 관련 파일 목록 + +### 모델 +| 파일 | 설명 | +|------|------| +| `app/Models/System/AiConfig.php` | AI 설정 Eloquent 모델 | + +### 컨트롤러 +| 파일 | 설명 | +|------|------| +| `app/Http/Controllers/System/AiConfigController.php` | CRUD + 연결 테스트 | + +### 서비스 +| 파일 | 설명 | +|------|------| +| `app/Services/GoogleCloudStorageService.php` | GCS 업로드/다운로드/삭제 | +| `app/Services/GeminiService.php` | Gemini API 호출 (명함 OCR 등) | + +### 설정 +| 파일 | 설명 | +|------|------| +| `config/gcs.php` | GCS 환경변수 설정 | + +### 뷰 +| 파일 | 설명 | +|------|------| +| `resources/views/system/ai-config/index.blade.php` | AI 설정 관리 페이지 | + +### 라우트 +```php +// routes/web.php +Route::prefix('system')->name('system.')->group(function () { + Route::resource('ai-config', AiConfigController::class)->except(['show', 'create', 'edit']); + Route::post('ai-config/{id}/toggle', [AiConfigController::class, 'toggle'])->name('ai-config.toggle'); + Route::post('ai-config/test', [AiConfigController::class, 'test'])->name('ai-config.test'); + Route::post('ai-config/test-gcs', [AiConfigController::class, 'testGcs'])->name('ai-config.test-gcs'); +}); +``` + +--- + +## 주요 메서드 + +### AiConfig 모델 + +```php +// Provider별 활성 설정 조회 +AiConfig::getActiveGemini(); // ?AiConfig +AiConfig::getActiveClaude(); // ?AiConfig +AiConfig::getActiveGcs(); // ?AiConfig +AiConfig::getActive('openai'); // ?AiConfig + +// GCS 전용 메서드 +$config->getBucketName(); // ?string +$config->getServiceAccountJson(); // ?array +$config->getServiceAccountPath(); // ?string +$config->isGcs(); // bool + +// Vertex AI 전용 메서드 +$config->isVertexAi(); // bool +$config->getProjectId(); // ?string +$config->getRegion(); // string (기본: us-central1) +``` + +### GoogleCloudStorageService + +```php +$gcs = new GoogleCloudStorageService(); + +// 사용 가능 여부 +$gcs->isAvailable(); // bool + +// 설정 소스 확인 +$gcs->getConfigSource(); // 'db' | 'env' | 'legacy' | 'none' + +// 파일 업로드 +$gcsUri = $gcs->upload($localPath, $objectName); // 'gs://bucket/object' | null + +// 서명된 다운로드 URL (60분 유효) +$url = $gcs->getSignedUrl($objectName, 60); // string | null + +// 파일 삭제 +$gcs->delete($objectName); // bool +``` + +--- + +## UI 구조 + +### 탭 구성 +- **AI 설정 탭**: Gemini, Claude, OpenAI 설정 관리 +- **스토리지 설정 탭**: GCS 설정 관리 + +### 기능 +- 설정 추가/수정/삭제 +- 활성화/비활성화 토글 (provider당 1개만 활성화) +- 연결 테스트 + +--- + +## 사용 예시 + +### GCS 업로드 (ConsultationController) + +```php +use App\Services\GoogleCloudStorageService; + +public function uploadAudio(Request $request) +{ + // 파일 저장 + $path = $file->store("tenant/consultations/{$tenantId}"); + $fullPath = storage_path('app/' . $path); + + // 10MB 이상이면 GCS에도 업로드 + if ($file->getSize() > 10 * 1024 * 1024) { + $gcs = new GoogleCloudStorageService(); + if ($gcs->isAvailable()) { + $gcsUri = $gcs->upload($fullPath, "consultations/{$tenantId}/" . basename($path)); + } + } +} +``` + +### 명함 OCR (GeminiService) + +```php +use App\Services\GeminiService; + +$gemini = new GeminiService(); +$result = $gemini->extractBusinessCard($imagePath); +``` + +--- + +## 배포 가이드 + +### 서버 최초 설정 + +1. `.env` 파일에 GCS 설정 추가: + ```env + GCS_BUCKET_NAME=production-bucket + GCS_SERVICE_ACCOUNT_PATH=/var/www/sales/apikey/google_service_account.json + ``` + +2. 서비스 계정 JSON 파일 배치: + ``` + /var/www/sales/apikey/google_service_account.json + ``` + +3. 설정 캐시 갱신: + ```bash + docker exec sam-mng-1 php artisan config:cache + ``` + +### 이후 배포 +- 코드 push만으로 동작 (설정 변경 불필요) +- UI에서 오버라이드하고 싶을 때만 DB 설정 사용 + +--- + +## 트러블슈팅 + +### GCS 업로드 실패 + +1. **설정 확인**: + ```php + $gcs = new GoogleCloudStorageService(); + dd($gcs->isAvailable(), $gcs->getConfigSource(), $gcs->getBucketName()); + ``` + +2. **로그 확인**: + ```bash + docker exec sam-mng-1 tail -f storage/logs/laravel.log | grep GCS + ``` + +3. **일반적인 원인**: + - 서비스 계정 파일 경로 오류 + - 서비스 계정에 Storage 권한 없음 + - 버킷 이름 오타 + +### AI API 연결 실패 + +1. **API 키 확인**: UI에서 "테스트" 버튼 클릭 +2. **모델명 확인**: provider별 지원 모델 확인 +3. **할당량 확인**: Google Cloud Console에서 API 할당량 확인 + +--- + +## 레거시 파일 위치 (참고) + +Docker 컨테이너 내부 경로: +``` +/var/www/sales/apikey/ +├── gcs_config.txt # bucket_name=xxx +├── google_service_account.json # GCP 서비스 계정 키 +└── gemini_api_key.txt # Gemini API 키 (레거시) +``` + +호스트 경로 (mng 기준): +``` +../sales/apikey/ +``` diff --git a/claudedocs/모달창_생성시_유의사항.md b/claudedocs/모달창_생성시_유의사항.md new file mode 100644 index 00000000..fa56c086 --- /dev/null +++ b/claudedocs/모달창_생성시_유의사항.md @@ -0,0 +1,233 @@ +# 모달창 생성 시 유의사항 + +## 개요 + +이 문서는 SAM 프로젝트에서 모달창을 구현할 때 발생할 수 있는 문제점과 해결 방법을 정리한 것입니다. + +--- + +## 1. pointer-events 문제 + +### 문제 상황 + +모달 배경 클릭을 방지하면서 모달 내부만 클릭 가능하게 하려고 다음과 같은 구조를 사용했을 때: + +```html + +