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 + +
+
+
+
+ +
+
+
+``` + +**증상**: 모달은 표시되지만 내부의 버튼, 입력 필드 등 모든 요소가 클릭되지 않음 (마치 돌덩어리처럼 동작) + +### 원인 + +- `pointer-events-none`이 부모에 있고 `pointer-events-auto`가 자식에 있는 구조 +- AJAX로 로드된 내용이 `pointer-events-auto` div 안에 들어가도, 그 안의 요소들에 pointer-events가 제대로 상속되지 않을 수 있음 +- 특히 동적으로 로드된 HTML에서 이 문제가 자주 발생 + +### 해결 방법 + +`pointer-events-none/auto` 구조를 사용하지 않고 단순화: + +```html + + +``` + +--- + +## 2. AJAX로 로드된 HTML에서 함수 호출 문제 + +### 문제 상황 + +```html + + +``` + +**증상**: `closeModal is not defined` 오류 발생 + +### 원인 + +- 함수가 `function closeModal() {}` 형태로 정의되면 호이스팅되지만, 모듈 스코프나 블록 스코프 안에 있을 수 있음 +- AJAX로 로드된 HTML에서 전역 함수에 접근하지 못할 수 있음 + +### 해결 방법 + +**방법 1: window 객체에 명시적 등록** + +```javascript +// 전역 스코프에 함수 등록 +window.closeModal = function() { + document.getElementById('modal').classList.add('hidden'); + document.body.style.overflow = ''; +}; +``` + +**방법 2: 이벤트 델리게이션 (권장)** + +```html + + +``` + +```javascript +// JavaScript: document 레벨에서 이벤트 감지 +document.addEventListener('click', function(e) { + const closeBtn = e.target.closest('[data-close-modal]'); + if (closeBtn) { + e.preventDefault(); + window.closeModal(); + } +}); +``` + +--- + +## 3. 배경 스크롤 방지 + +### 모달 열 때 + +```javascript +document.body.style.overflow = 'hidden'; +``` + +### 모달 닫을 때 + +```javascript +document.body.style.overflow = ''; +``` + +--- + +## 4. ESC 키로 모달 닫기 + +```javascript +document.addEventListener('keydown', function(e) { + if (e.key === 'Escape') { + window.closeModal(); + } +}); +``` + +--- + +## 5. 완전한 모달 구현 예시 + +### HTML 구조 + +```html + + +``` + +### JavaScript + +```javascript +// 전역 함수 등록 +window.openExampleModal = function(id) { + const modal = document.getElementById('exampleModal'); + const content = document.getElementById('exampleModalContent'); + + modal.classList.remove('hidden'); + document.body.style.overflow = 'hidden'; + + // AJAX로 내용 로드 + fetch(`/api/example/${id}`) + .then(response => response.text()) + .then(html => { + content.innerHTML = html; + }); +}; + +window.closeExampleModal = function() { + document.getElementById('exampleModal').classList.add('hidden'); + document.body.style.overflow = ''; +}; + +// ESC 키 지원 +document.addEventListener('keydown', function(e) { + if (e.key === 'Escape') { + window.closeExampleModal(); + } +}); + +// 이벤트 델리게이션 (닫기 버튼) +document.addEventListener('click', function(e) { + if (e.target.closest('[data-close-modal]')) { + e.preventDefault(); + window.closeExampleModal(); + } +}); +``` + +### AJAX로 로드되는 부분 뷰 + +```html +
+
+

모달 제목

+ + +
+ + + +
+ + +
+
+``` + +--- + +## 6. 체크리스트 + +모달 구현 시 다음 사항을 확인하세요: + +- [ ] `pointer-events-none/auto` 구조를 사용하지 않음 +- [ ] 함수를 `window` 객체에 등록했음 +- [ ] 닫기 버튼에 `data-close-modal` 속성을 추가했음 +- [ ] document 레벨 이벤트 델리게이션을 설정했음 +- [ ] 모달 열 때 `body.style.overflow = 'hidden'` 설정 +- [ ] 모달 닫을 때 `body.style.overflow = ''` 복원 +- [ ] ESC 키 이벤트 리스너 등록 +- [ ] z-index가 다른 요소들과 충돌하지 않음 (보통 z-50 사용) + +--- + +## 관련 파일 + +- `/resources/views/sales/managers/index.blade.php` - 영업파트너 관리 모달 구현 예시 +- `/resources/views/sales/managers/partials/show-modal.blade.php` - 상세 모달 부분 뷰 +- `/resources/views/sales/managers/partials/edit-modal.blade.php` - 수정 모달 부분 뷰 diff --git a/claudedocs/상품관리정보.md b/claudedocs/상품관리정보.md new file mode 100644 index 00000000..027c404e --- /dev/null +++ b/claudedocs/상품관리정보.md @@ -0,0 +1,443 @@ +# SAM 상품관리 시스템 개발 문서 + +> 작성일: 2026-01-29 +> 목적: SAM 솔루션 상품의 가격 구조 및 계약 관리 시스템 문서화 + +--- + +## 1. 개요 + +SAM 상품관리 시스템은 본사(HQ)에서 SAM 솔루션 상품을 관리하고, 영업 과정에서 고객사(테넌트)에게 상품을 선택/계약하는 기능을 제공합니다. + +### 1.1 주요 기능 +- **상품 카테고리 관리**: 업종별 상품 분류 (제조업체, 공사업체 등) +- **상품 관리**: 개별 솔루션 상품 CRUD +- **계약 상품 선택**: 영업 시나리오에서 고객사별 상품 선택 +- **가격 커스터마이징**: 재량권 상품의 가격 조정 + +--- + +## 2. 데이터베이스 구조 + +### 2.1 상품 카테고리 테이블 (`sales_product_categories`) + +| 컬럼 | 타입 | 설명 | +|------|------|------| +| `id` | bigint | PK | +| `code` | varchar | 카테고리 코드 (예: `manufacturer`, `contractor`) | +| `name` | varchar | 카테고리명 (예: "제조 업체", "공사 업체") | +| `description` | text | 설명 | +| `base_storage` | varchar | 기본 저장소 경로 | +| `display_order` | int | 정렬 순서 | +| `is_active` | boolean | 활성화 여부 | +| `deleted_at` | timestamp | 소프트 삭제 | + +### 2.2 상품 테이블 (`sales_products`) + +| 컬럼 | 타입 | 설명 | +|------|------|------| +| `id` | bigint | PK | +| `category_id` | bigint | FK → sales_product_categories | +| `code` | varchar | 상품 코드 | +| `name` | varchar | 상품명 | +| `description` | text | 상품 설명 | +| `development_fee` | decimal(15,2) | **개발비** (원가) | +| `registration_fee` | decimal(15,2) | **가입비** (고객 청구 금액) | +| `subscription_fee` | decimal(15,2) | **월 구독료** | +| `partner_commission_rate` | decimal(5,2) | **영업파트너 수당율** (%) | +| `manager_commission_rate` | decimal(5,2) | **매니저 수당율** (%) | +| `allow_flexible_pricing` | boolean | 재량권 허용 여부 | +| `is_required` | boolean | 필수 상품 여부 | +| `display_order` | int | 정렬 순서 | +| `is_active` | boolean | 활성화 여부 | +| `deleted_at` | timestamp | 소프트 삭제 | + +### 2.3 계약 상품 테이블 (`sales_contract_products`) + +| 컬럼 | 타입 | 설명 | +|------|------|------| +| `id` | bigint | PK | +| `tenant_id` | bigint | FK → tenants (고객사) | +| `management_id` | bigint | FK → sales_tenant_managements | +| `category_id` | bigint | FK → sales_product_categories | +| `product_id` | bigint | FK → sales_products | +| `registration_fee` | decimal(15,2) | 실제 청구 가입비 (커스텀 가능) | +| `subscription_fee` | decimal(15,2) | 실제 청구 구독료 (커스텀 가능) | +| `discount_rate` | decimal(5,2) | 할인율 | +| `notes` | text | 비고 | +| `created_by` | bigint | 등록자 | + +--- + +## 3. 가격 구조 + +### 3.1 가격 체계 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 가격 구조 다이어그램 │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ 개발비 (Development Fee) │ +│ ├── 원가 개념, 내부 관리용 │ +│ └── 예: ₩80,000,000 │ +│ │ +│ 가입비 (Registration Fee) │ +│ ├── 고객에게 청구하는 금액 │ +│ ├── 일반적으로 개발비의 25% │ +│ └── 예: ₩20,000,000 (80,000,000 × 25%) │ +│ │ +│ 월 구독료 (Subscription Fee) │ +│ ├── 매월 청구되는 구독 비용 │ +│ └── 예: ₩500,000/월 │ +│ │ +│ 수당 (Commission) │ +│ ├── 영업파트너 수당: 가입비 × 20% │ +│ ├── 매니저 수당: 가입비 × 5% │ +│ └── 총 수당율: 25% │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 3.2 가격 계산 공식 + +```php +// 가입비 = 개발비 × 25% (기본값) +$registration_fee = $development_fee * 0.25; + +// 영업파트너 수당 = 가입비 × 20% +$partner_commission = $registration_fee * 0.20; + +// 매니저 수당 = 가입비 × 5% +$manager_commission = $registration_fee * 0.05; + +// 총 수당 +$total_commission = $partner_commission + $manager_commission; +``` + +### 3.3 표시 예시 (UI) + +``` +┌──────────────────────────────────────────┐ +│ SAM 기본 솔루션 │ +│ │ +│ 가입비: ₩80,000,000 → ₩20,000,000 │ +│ (취소선) (할인가) │ +│ │ +│ 월 구독료: ₩500,000 │ +│ │ +│ 수당: 영업파트너 20% | 매니저 5% │ +└──────────────────────────────────────────┘ +``` + +--- + +## 4. 상품 카테고리별 구성 + +### 4.1 제조 업체 (manufacturer) + +| 상품명 | 개발비 | 가입비 | 월 구독료 | 파트너 수당 | 매니저 수당 | 필수 | +|--------|--------|--------|-----------|-------------|-------------|------| +| SAM 기본 솔루션 | ₩80,000,000 | ₩20,000,000 | ₩500,000 | 20% | 5% | O | +| ERP 연동 모듈 | ₩40,000,000 | ₩10,000,000 | ₩200,000 | 20% | 5% | - | +| MES 연동 모듈 | ₩60,000,000 | ₩15,000,000 | ₩300,000 | 20% | 5% | - | +| 품질관리 모듈 | ₩20,000,000 | ₩5,000,000 | ₩100,000 | 20% | 5% | - | +| 재고관리 모듈 | ₩16,000,000 | ₩4,000,000 | ₩80,000 | 20% | 5% | - | + +### 4.2 공사 업체 (contractor) + +| 상품명 | 개발비 | 가입비 | 월 구독료 | 파트너 수당 | 매니저 수당 | 필수 | +|--------|--------|--------|-----------|-------------|-------------|------| +| SAM 공사관리 | ₩60,000,000 | ₩15,000,000 | ₩400,000 | 20% | 5% | O | +| 현장관리 모듈 | ₩24,000,000 | ₩6,000,000 | ₩150,000 | 20% | 5% | - | +| 안전관리 모듈 | ₩20,000,000 | ₩5,000,000 | ₩100,000 | 20% | 5% | - | +| 공정관리 모듈 | ₩32,000,000 | ₩8,000,000 | ₩200,000 | 20% | 5% | - | + +--- + +## 5. 모델 클래스 + +### 5.1 SalesProduct 모델 + +**파일 위치**: `app/Models/Sales/SalesProduct.php` + +```php +class SalesProduct extends Model +{ + use SoftDeletes; + + protected $fillable = [ + 'category_id', 'code', 'name', 'description', + 'development_fee', 'registration_fee', 'subscription_fee', + 'partner_commission_rate', 'manager_commission_rate', + 'allow_flexible_pricing', 'is_required', + 'display_order', 'is_active', + ]; + + // Accessors + 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); + } +} +``` + +### 5.2 SalesProductCategory 모델 + +**파일 위치**: `app/Models/Sales/SalesProductCategory.php` + +```php +class SalesProductCategory extends Model +{ + use SoftDeletes; + + protected $fillable = [ + 'code', 'name', 'description', + 'base_storage', 'display_order', 'is_active', + ]; + + 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'); + } +} +``` + +### 5.3 SalesContractProduct 모델 + +**파일 위치**: `app/Models/Sales/SalesContractProduct.php` + +```php +class SalesContractProduct extends Model +{ + protected $fillable = [ + 'tenant_id', 'management_id', 'category_id', 'product_id', + 'registration_fee', 'subscription_fee', + 'discount_rate', 'notes', '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; + } +} +``` + +--- + +## 6. API 엔드포인트 + +### 6.1 상품 관리 (HQ 전용) + +| Method | URI | 설명 | +|--------|-----|------| +| GET | `/sales/products` | 상품 목록 페이지 | +| POST | `/sales/products` | 상품 생성 | +| PUT | `/sales/products/{id}` | 상품 수정 | +| DELETE | `/sales/products/{id}` | 상품 삭제 | +| POST | `/sales/products/categories` | 카테고리 생성 | +| PUT | `/sales/products/categories/{id}` | 카테고리 수정 | +| DELETE | `/sales/products/categories/{id}` | 카테고리 삭제 | + +### 6.2 계약 상품 선택 (영업 시나리오) + +| Method | URI | 설명 | +|--------|-----|------| +| POST | `/sales/contracts/products` | 상품 선택 저장 | + +**요청 본문**: +```json +{ + "tenant_id": 123, + "category_id": 1, + "products": [ + { + "product_id": 1, + "category_id": 1, + "registration_fee": 20000000, + "subscription_fee": 500000 + } + ] +} +``` + +--- + +## 7. 영업 시나리오 연동 + +### 7.1 계약 체결 단계 (Step 6) + +영업 시나리오의 6단계 "계약 체결"에서 상품 선택 UI가 표시됩니다. + +**파일 위치**: `resources/views/sales/modals/partials/product-selection.blade.php` + +### 7.2 상품 선택 흐름 + +``` +1. 영업 시나리오 모달 열기 + ↓ +2. "계약 체결" 탭 선택 + ↓ +3. 카테고리 탭 선택 (제조업체/공사업체) + ↓ +4. 상품 체크박스 선택/해제 + ↓ +5. 합계 자동 계산 (선택된 카테고리 기준) + ↓ +6. "상품 선택 저장" 버튼 클릭 + ↓ +7. sales_contract_products 테이블에 저장 +``` + +### 7.3 내 계약 현황 표시 + +**파일 위치**: `resources/views/sales/dashboard/partials/tenant-list.blade.php` + +각 테넌트 행에 계약 금액 정보가 표시됩니다: +- 총 가입비: `SalesContractProduct::getTotalRegistrationFee($tenantId)` +- 총 구독료: `SalesContractProduct::getTotalSubscriptionFee($tenantId)` + +--- + +## 8. 주요 속성 설명 + +### 8.1 `is_required` (필수 상품) + +- `true`: 해제 불가, 항상 선택된 상태 +- 예: "SAM 기본 솔루션"은 필수 + +### 8.2 `allow_flexible_pricing` (재량권) + +- `true`: 영업 담당자가 가격 조정 가능 +- UI에서 "재량권" 뱃지로 표시 + +### 8.3 개발비 vs 가입비 + +| 구분 | 개발비 (development_fee) | 가입비 (registration_fee) | +|------|-------------------------|--------------------------| +| 용도 | 내부 원가 관리 | 고객 청구 금액 | +| 표시 | 취소선으로 표시 | 실제 금액으로 표시 | +| 비율 | 100% (기준) | 25% (기본) | +| 수당 계산 | 기준 금액 | - | + +--- + +## 9. 수당 계산 예시 + +### 9.1 단일 상품 계약 + +``` +상품: SAM 기본 솔루션 +개발비: ₩80,000,000 +가입비: ₩20,000,000 + +영업파트너 수당 = ₩20,000,000 × 20% = ₩4,000,000 +매니저 수당 = ₩20,000,000 × 5% = ₩1,000,000 +총 수당 = ₩5,000,000 +``` + +### 9.2 복수 상품 계약 + +``` +상품1: SAM 기본 솔루션 (가입비 ₩20,000,000) +상품2: ERP 연동 모듈 (가입비 ₩10,000,000) +상품3: 품질관리 모듈 (가입비 ₩5,000,000) + +총 가입비 = ₩35,000,000 + +영업파트너 수당 = ₩35,000,000 × 20% = ₩7,000,000 +매니저 수당 = ₩35,000,000 × 5% = ₩1,750,000 +총 수당 = ₩8,750,000 +``` + +--- + +## 10. 확장 가능성 + +### 10.1 추가 개발 가능 기능 + +1. **수당 정산 시스템**: 월별 수당 정산 및 지급 관리 +2. **가격 이력 관리**: 상품 가격 변경 이력 추적 +3. **할인 정책**: 다양한 할인 유형 (볼륨, 기간, 특별) +4. **번들 상품**: 여러 상품을 묶은 패키지 상품 +5. **구독 관리**: 구독 갱신, 해지, 업그레이드 관리 + +### 10.2 API 확장 + +```php +// 수당 계산 API +GET /api/sales/commissions/calculate?tenant_id={id} + +// 가격 이력 조회 +GET /api/sales/products/{id}/price-history + +// 할인 적용 +POST /api/sales/contracts/{id}/apply-discount +``` + +--- + +## 11. 관련 파일 목록 + +### 11.1 모델 +- `app/Models/Sales/SalesProduct.php` +- `app/Models/Sales/SalesProductCategory.php` +- `app/Models/Sales/SalesContractProduct.php` + +### 11.2 컨트롤러 +- `app/Http/Controllers/Sales/SalesProductController.php` +- `app/Http/Controllers/Sales/SalesContractController.php` + +### 11.3 뷰 +- `resources/views/sales/products/index.blade.php` (상품관리 페이지) +- `resources/views/sales/products/partials/product-list.blade.php` (상품 목록) +- `resources/views/sales/modals/partials/product-selection.blade.php` (상품 선택) +- `resources/views/sales/dashboard/partials/tenant-list.blade.php` (계약 현황) + +### 11.4 마이그레이션 (API 프로젝트) +- `database/migrations/xxxx_create_sales_product_categories_table.php` +- `database/migrations/xxxx_create_sales_products_table.php` +- `database/migrations/xxxx_create_sales_contract_products_table.php` +- `database/migrations/xxxx_add_registration_fee_to_sales_products_table.php` +- `database/migrations/xxxx_add_partner_manager_commission_to_sales_products_table.php` + +--- + +## 12. 변경 이력 + +| 날짜 | 변경 내용 | 작성자 | +|------|----------|--------| +| 2026-01-29 | 최초 문서 작성 | Claude | +| 2026-01-29 | 가입비/개발비 분리, 수당율 분리 (파트너/매니저) | Claude | diff --git a/claudedocs/홈택스 매입매출 조회성공.md b/claudedocs/홈택스 매입매출 조회성공.md new file mode 100644 index 00000000..0e1ef0c9 --- /dev/null +++ b/claudedocs/홈택스 매입매출 조회성공.md @@ -0,0 +1,164 @@ +# 바로빌 홈택스 매입/매출 API 연동 - 문제 해결 기록 + +> 작성일: 2026-01-28 +> 해결 소요: 약 2일 (2026-01-26 ~ 2026-01-28) + +## 개요 + +바로빌 API를 통해 홈택스 매입/매출 세금계산서를 조회하는 기능 개발 중 발생한 오류와 해결 과정을 기록합니다. + +## 사용 API + +| API 메소드 | 용도 | +|-----------|------| +| `GetPeriodTaxInvoiceSalesList` | 기간별 매출 세금계산서 목록 조회 | +| `GetPeriodTaxInvoicePurchaseList` | 기간별 매입 세금계산서 목록 조회 | + +## 발생한 오류들 + +### 1. -10008 날짜형식 오류 + +**오류 메시지:** +``` +-10008 날짜형식이 잘못되었습니다. +``` + +**원인:** +날짜 파라미터에 하이픈(`-`)이 포함됨 + +**잘못된 예:** +```json +{ + "StartDate": "2026-01-01", + "EndDate": "2026-01-26" +} +``` + +**해결:** +```json +{ + "StartDate": "20260101", + "EndDate": "20260126" +} +``` + +**Laravel 코드:** +```php +// 하이픈 없는 YYYYMMDD 형식 사용 +$startDate = date('Ymd', strtotime('-1 month')); +$endDate = date('Ymd'); +``` + +--- + +### 2. -11010 과세형태 오류 + +**오류 메시지:** +``` +-11010 과세형태가 잘못되었습니다. (TaxType) +``` + +**원인:** +`TaxType=0` (전체)은 바로빌 API에서 **지원하지 않음** + +**잘못된 예:** +```json +{ + "TaxType": 0 +} +``` + +**바로빌 API TaxType 값:** +| 값 | 의미 | +|----|------| +| 0 | ❌ 미지원 | +| 1 | 과세 + 영세 | +| 3 | 면세 | + +**해결:** +전체 조회 시 TaxType=1과 TaxType=3을 **각각 조회하여 합침** + +```php +// TaxType이 0(전체)인 경우 1(과세+영세)과 3(면세)를 각각 조회하여 합침 +$taxTypesToQuery = ($taxType === 0) ? [1, 3] : [$taxType]; +$allInvoices = []; + +foreach ($taxTypesToQuery as $queryTaxType) { + $result = $this->callSoap('GetPeriodTaxInvoiceSalesList', [ + 'UserID' => $userId, + 'TaxType' => $queryTaxType, + // ... + ]); + + if ($result['success']) { + $parsed = $this->parseInvoices($result['data'], 'sales'); + $allInvoices = array_merge($allInvoices, $parsed['invoices']); + } +} + +// 작성일 기준 최신순 정렬 +usort($allInvoices, fn($a, $b) => strcmp($b['writeDate'] ?? '', $a['writeDate'] ?? '')); +``` + +--- + +### 3. DateType 권장사항 + +**바로빌 권장:** +`DateType=3` (전송일자) 사용 권장 + +**DateType 값:** +| 값 | 의미 | 비고 | +|----|------|------| +| 1 | 작성일 기준 | - | +| 3 | 전송일자 기준 | **권장** | + +**적용:** +```php +$result = $this->callSoap('GetPeriodTaxInvoiceSalesList', [ + 'UserID' => $userId, + 'TaxType' => $queryTaxType, + 'DateType' => 3, // 전송일자 기준 (권장) + 'StartDate' => $startDate, + 'EndDate' => $endDate, + 'CountPerPage' => $limit, + 'CurrentPage' => $page +]); +``` + +--- + +## 최종 작동 파라미터 + +```json +{ + "CERTKEY": "인증키", + "CorpNum": "사업자번호", + "UserID": "바로빌ID", + "TaxType": 1, + "DateType": 3, + "StartDate": "20251231", + "EndDate": "20260130", + "CountPerPage": 100, + "CurrentPage": 1 +} +``` + +## 관련 파일 + +- `app/Http/Controllers/Barobill/HometaxController.php` + - `sales()` - 매출 조회 + - `purchases()` - 매입 조회 + - `diagnose()` - 서비스 진단 + +## 참고 자료 + +- 바로빌 개발자 문서: https://dev.barobill.co.kr/docs/taxinvoice +- 바로빌 운영센터 메일 (2026-01-27, 2026-01-28) + +## 교훈 + +1. **API 문서를 꼼꼼히 확인** - TaxType=0이 전체를 의미할 것 같지만 실제로는 미지원 +2. **날짜 형식 주의** - 한국 API는 하이픈 없는 YYYYMMDD 형식을 많이 사용 +3. **권장사항 따르기** - DateType=3 (전송일자) 사용 권장 +4. **에러 발생 시 바로빌 고객센터 문의** - 로그를 분석해서 정확한 원인을 알려줌 diff --git a/config/gcs.php b/config/gcs.php new file mode 100644 index 00000000..44078e43 --- /dev/null +++ b/config/gcs.php @@ -0,0 +1,21 @@ + env('GCS_BUCKET_NAME'), + + // 서비스 계정 파일 경로 + 'service_account_path' => env('GCS_SERVICE_ACCOUNT_PATH', '/var/www/sales/apikey/google_service_account.json'), + + // DB 설정 사용 여부 (false면 .env만 사용) + 'use_db_config' => env('GCS_USE_DB_CONFIG', true), +]; diff --git a/config/sales_scenario.php b/config/sales_scenario.php new file mode 100644 index 00000000..91c45408 --- /dev/null +++ b/config/sales_scenario.php @@ -0,0 +1,435 @@ + [ + [ + 'id' => 1, + 'title' => '사전 준비', + 'subtitle' => 'Preparation', + 'icon' => 'search', + 'color' => 'blue', + 'bg_class' => 'bg-blue-100', + 'text_class' => 'text-blue-600', + 'description' => '고객사를 만나기 전, 철저한 분석을 통해 성공 확률을 높이는 단계입니다.', + 'checkpoints' => [ + [ + 'id' => 'prep_1', + 'title' => '고객사 심층 분석', + 'detail' => '홈페이지, 뉴스 등을 통해 이슈와 비전을 파악하세요.', + 'pro_tip' => '직원들의 불만 사항을 미리 파악하면 미팅 시 강력한 무기가 됩니다.', + ], + [ + 'id' => 'prep_2', + 'title' => '재무 건전성 확인', + 'detail' => '매출액, 영업이익 추이를 확인하고 IT 투자 여력을 가늠해보세요.', + 'pro_tip' => '성장 추세라면 \'확장성\'과 \'관리 효율\'을 강조하세요.', + ], + [ + 'id' => 'prep_3', + 'title' => '경쟁사 및 시장 동향', + 'detail' => '핵심 기능에 집중하여 도입 속도가 빠르다는 점을 정리하세요.', + 'pro_tip' => '경쟁사를 비방하기보다 차별화된 가치를 제시하세요.', + ], + [ + 'id' => 'prep_4', + 'title' => '가설 수립 (Hypothesis)', + 'detail' => '구체적인 페인포인트 가설을 세우고 질문을 준비하세요.', + 'pro_tip' => '\'만약 ~하다면\' 화법으로 고객의 \'Yes\'를 유도하세요.', + ], + ], + ], + [ + 'id' => 2, + 'title' => '접근 및 탐색', + 'subtitle' => 'Approach', + 'icon' => 'phone', + 'color' => 'indigo', + 'bg_class' => 'bg-indigo-100', + 'text_class' => 'text-indigo-600', + 'description' => '담당자와의 첫 접점을 만들고, 미팅 기회를 확보하는 단계입니다.', + 'checkpoints' => [ + [ + 'id' => 'approach_1', + 'title' => 'Key-man 식별 및 컨택', + 'detail' => '실무 책임자(팀장급)와 의사결정권자(임원급) 라인을 파악하세요.', + 'pro_tip' => '전달드릴 자료가 있다고 하여 Gatekeeper를 통과하세요.', + ], + [ + 'id' => 'approach_2', + 'title' => '맞춤형 콜드메일/콜', + 'detail' => '사전 조사 내용을 바탕으로 해결 방안을 제안하세요.', + 'pro_tip' => '제목에 고객사 이름을 넣어 클릭률을 높이세요.', + ], + [ + 'id' => 'approach_3', + 'title' => '미팅 일정 확정', + 'detail' => '인사이트 공유를 목적으로 미팅을 제안하세요.', + 'pro_tip' => '두 가지 시간대를 제시하여 양자택일을 유도하세요.', + ], + ], + ], + [ + 'id' => 3, + 'title' => '현장 진단', + 'subtitle' => 'Diagnosis', + 'icon' => 'clipboard-check', + 'color' => 'purple', + 'bg_class' => 'bg-purple-100', + 'text_class' => 'text-purple-600', + 'description' => '고객의 업무 현장을 직접 확인하고 진짜 문제를 찾아내는 단계입니다.', + 'checkpoints' => [ + [ + 'id' => 'diag_1', + 'title' => 'AS-IS 프로세스 맵핑', + 'detail' => '고객과 함께 업무 흐름도를 그리며 병목을 찾으세요.', + 'pro_tip' => '고객 스스로 문제를 깨닫게 하는 것이 가장 효과적입니다.', + ], + [ + 'id' => 'diag_2', + 'title' => '비효율/리스크 식별', + 'detail' => '데이터 누락, 중복 입력 등 리스크를 수치화하세요.', + 'pro_tip' => '불편함을 시간과 비용으로 환산하여 설명하세요.', + ], + [ + 'id' => 'diag_3', + 'title' => 'To-Be 이미지 스케치', + 'detail' => '도입 후 업무가 어떻게 간소화될지 시각화하세요.', + 'pro_tip' => '비포/애프터의 극명한 차이를 보여주세요.', + ], + ], + ], + [ + 'id' => 4, + 'title' => '솔루션 제안', + 'subtitle' => 'Proposal', + 'icon' => 'presentation', + 'color' => 'pink', + 'bg_class' => 'bg-pink-100', + 'text_class' => 'text-pink-600', + 'description' => 'SAM을 통해 고객의 문제를 어떻게 해결할 수 있는지 증명하는 단계입니다.', + 'checkpoints' => [ + [ + 'id' => 'proposal_1', + 'title' => '맞춤형 데모 시연', + 'detail' => '핵심 기능을 위주로 고객사 데이터를 넣어 시연하세요.', + 'pro_tip' => '고객사 로고를 넣어 \'이미 우리 것\'이라는 느낌을 주세요.', + ], + [ + 'id' => 'proposal_2', + 'title' => 'ROI 분석 보고서', + 'detail' => '비용 대비 절감 가능한 수치를 산출하여 증명하세요.', + 'pro_tip' => '보수적인 ROI가 훨씬 더 높은 신뢰를 줍니다.', + ], + [ + 'id' => 'proposal_3', + 'title' => '단계별 도입 로드맵', + 'detail' => '부담을 줄이기 위해 단계적 확산 방안을 제시하세요.', + 'pro_tip' => '1단계는 핵심 문제 해결에만 집중하세요.', + ], + ], + ], + [ + 'id' => 5, + 'title' => '협상 및 조율', + 'subtitle' => 'Negotiation', + 'icon' => 'scale', + 'color' => 'orange', + 'bg_class' => 'bg-orange-100', + 'text_class' => 'text-orange-600', + 'description' => '도입을 가로막는 장애물을 제거하고 조건을 합의하는 단계입니다.', + 'checkpoints' => [ + [ + 'id' => 'nego_1', + 'title' => '가격/조건 협상', + 'detail' => '할인 대신 범위나 기간 조정 등으로 합의하세요.', + 'pro_tip' => 'Give & Take 원칙을 지키며 기대를 관리하세요.', + ], + [ + 'id' => 'nego_2', + 'title' => '의사결정권자 설득', + 'detail' => 'CEO/CFO의 관심사에 맞는 보고용 장표를 제공하세요.', + 'pro_tip' => '실무자가 내부 보고 사업을 잘하게 돕는 것이 핵심입니다.', + ], + ], + ], + [ + 'id' => 6, + 'title' => '계약 체결', + 'subtitle' => 'Closing', + 'icon' => 'file-signature', + 'color' => 'green', + 'bg_class' => 'bg-green-100', + 'text_class' => 'text-green-600', + 'description' => '공식적인 파트너십을 맺고 법적 효력을 발생시키는 단계입니다.', + 'checkpoints' => [ + [ + 'id' => 'close_1', + 'title' => '계약 체결 완료', + 'detail' => '계약서 날인/교부, 세금계산서 발행, 후속 지원 일정까지 한 번에 진행하세요.', + 'pro_tip' => '가입비 입금이 완료되어야 매니저에게 프로젝트가 이관됩니다.', + ], + ], + ], + ], + + /* + |-------------------------------------------------------------------------- + | 매니저 시나리오 단계 (MANAGER_SCENARIO_STEPS) + |-------------------------------------------------------------------------- + | 매니저가 프로젝트를 인수받아 착수하기까지의 6단계 프로세스 + */ + 'manager_steps' => [ + [ + 'id' => 1, + 'title' => '영업 이관', + 'subtitle' => 'Handover', + 'icon' => 'arrow-right-left', + 'color' => 'blue', + 'bg_class' => 'bg-blue-100', + 'text_class' => 'text-blue-600', + 'description' => '영업팀으로부터 고객 정보를 전달받고, 프로젝트의 배경과 핵심 요구사항을 파악하는 단계입니다.', + 'tips' => '잘못된 시작은 엉뚱한 결말을 낳습니다. 영업팀의 약속을 검증하세요.', + 'checkpoints' => [ + [ + 'id' => 'handover_1', + 'title' => '영업 히스토리 리뷰', + 'detail' => '영업 담당자가 작성한 미팅록, 고객의 페인포인트, 예산 범위, 예상 일정 등을 꼼꼼히 확인하세요.', + 'pro_tip' => '영업 담당자에게 \'고객이 가장 꽂힌 포인트\'가 무엇인지 꼭 물어보세요. 그게 프로젝트의 CSF입니다.', + ], + [ + 'id' => 'handover_2', + 'title' => '고객사 기본 정보 파악', + 'detail' => '고객사의 업종, 규모, 주요 경쟁사 등을 파악하여 커뮤니케이션 톤앤매너를 준비하세요.', + 'pro_tip' => 'IT 지식이 부족한 고객이라면 전문 용어 사용을 자제하고 쉬운 비유를 준비해야 합니다.', + ], + [ + 'id' => 'handover_3', + 'title' => 'RFP/요구사항 문서 분석', + 'detail' => '고객이 전달한 요구사항 문서(RFP 등)가 있다면 기술적으로 실현 가능한지 1차 검토하세요.', + 'pro_tip' => '모호한 문장을 찾아내어 구체적인 수치나 기능으로 정의할 준비를 하세요.', + ], + [ + 'id' => 'handover_4', + 'title' => '내부 킥오프 (영업-매니저)', + 'detail' => '영업팀과 함께 프로젝트의 리스크 요인(까다로운 담당자 등)을 사전에 공유받으세요.', + 'pro_tip' => '영업 단계에서 \'무리하게 약속한 기능\'이 있는지 반드시 체크해야 합니다.', + ], + ], + ], + [ + 'id' => 2, + 'title' => '요구사항 파악', + 'subtitle' => 'Requirements', + 'icon' => 'search', + 'color' => 'indigo', + 'bg_class' => 'bg-indigo-100', + 'text_class' => 'text-indigo-600', + 'description' => '고객과 직접 만나 구체적인 니즈를 청취하고, 숨겨진 요구사항까지 발굴하는 단계입니다.', + 'tips' => '고객은 자기가 뭘 원하는지 모를 때가 많습니다. 질문으로 답을 찾아주세요.', + 'checkpoints' => [ + [ + 'id' => 'req_1', + 'title' => '고객 인터뷰 및 실사', + 'detail' => '현업 담당자를 만나 실제 업무 프로세스를 확인하고 시스템이 필요한 진짜 이유를 찾으세요.', + 'pro_tip' => '\'왜 이 기능이 필요하세요?\'라고 3번 물어보세요(5 Whys). 목적을 찾아야 합니다.', + ], + [ + 'id' => 'req_2', + 'title' => '요구사항 구체화 (Scope)', + 'detail' => '고객의 요구사항을 기능 단위로 쪼개고 우선순위(Must/Should/Could)를 매기세요.', + 'pro_tip' => '\'오픈 시점에 반드시 필요한 기능\'과 \'추후 고도화할 기능\'을 명확히 구분해 주세요.', + ], + [ + 'id' => 'req_3', + 'title' => '제약 사항 확인', + 'detail' => '예산, 일정, 레거시 시스템 연동, 보안 규정 등 프로젝트의 제약 조건을 명확히 하세요.', + 'pro_tip' => '특히 \'데이터 이관\' 이슈를 조심하세요. 엑셀 데이터가 엉망인 경우가 태반입니다.', + ], + [ + 'id' => 'req_4', + 'title' => '유사 레퍼런스 제시', + 'detail' => '비슷한 고민을 했던 다른 고객사의 해결 사례를 보여주며 제안하는 방향의 신뢰를 얻으세요.', + 'pro_tip' => '\'A사도 이렇게 푸셨습니다\'라는 한마디가 백 마디 설명보다 강력합니다.', + ], + ], + ], + [ + 'id' => 3, + 'title' => '개발자 협의', + 'subtitle' => 'Dev Consult', + 'icon' => 'code', + 'color' => 'purple', + 'bg_class' => 'bg-purple-100', + 'text_class' => 'text-purple-600', + 'description' => '파악된 요구사항을 개발팀에 전달하고 기술적 실현 가능성과 공수를 산정합니다.', + 'tips' => '개발자는 \'기능\'을 만들지만, 매니저는 \'가치\'를 만듭니다. 통역사가 되어주세요.', + 'checkpoints' => [ + [ + 'id' => 'dev_1', + 'title' => '요구사항 기술 검토', + 'detail' => '개발 리드와 함께 고객의 요구사항이 기술적으로 구현 가능한지 검토하세요.', + 'pro_tip' => '개발자가 \'안 돼요\'라고 하면 \'왜 안 되는지\', \'대안은 무엇인지\'를 반드시 물어보세요.', + ], + [ + 'id' => 'dev_2', + 'title' => '공수 산정 (Estimation)', + 'detail' => '기능별 개발 예상 시간(M/M)을 산출하고 필요한 리소스를 파악하세요.', + 'pro_tip' => '개발 공수는 항상 버퍼(Buffer)를 20% 정도 두세요. 버그나 스펙 변경은 반드시 일어납니다.', + ], + [ + 'id' => 'dev_3', + 'title' => '아키텍처/스택 선정', + 'detail' => '프로젝트에 적합한 기술 스택과 시스템 아키텍처를 확정하세요.', + 'pro_tip' => '최신 기술보다 유지보수 용이성과 개발팀의 숙련도를 최우선으로 고려하세요.', + ], + [ + 'id' => 'dev_4', + 'title' => '리스크 식별 및 대안 수립', + 'detail' => '기술적 난이도가 높은 기능 등 리스크를 식별하고 대안(Plan B)을 마련하세요.', + 'pro_tip' => '리스크는 감추지 말고 공유해야 합니다. 미리 말하면 관리입니다.', + ], + ], + ], + [ + 'id' => 4, + 'title' => '제안 및 견적', + 'subtitle' => 'Proposal', + 'icon' => 'file-text', + 'color' => 'pink', + 'bg_class' => 'bg-pink-100', + 'text_class' => 'text-pink-600', + 'description' => '개발팀 검토 내용을 바탕으로 수행 계획서(SOW)와 견적서를 작성하여 제안합니다.', + 'tips' => '견적서는 숫자가 아니라 \'신뢰\'를 담아야 합니다.', + 'checkpoints' => [ + [ + 'id' => 'prop_1', + 'title' => 'WBS 및 일정 계획 수립', + 'detail' => '분석/설계/개발/테스트/오픈 등 단계별 상세 일정을 수립하세요.', + 'pro_tip' => '고객의 검수(UAT) 기간을 충분히 잡으세요. 고객은 생각보다 바빠서 피드백이 늦어집니다.', + ], + [ + 'id' => 'prop_2', + 'title' => '견적서(Quotation) 작성', + 'detail' => '개발 공수, 솔루션 비용, 인프라 비용 등을 포함한 상세 견적서를 작성하세요.', + 'pro_tip' => '\'기능별 상세 견적\'을 제공하면 신뢰도가 높아지고 네고 방어에도 유리합니다.', + ], + [ + 'id' => 'prop_3', + 'title' => '제안서(SOW) 작성', + 'detail' => '범위(Scope), 수행 방법론, 산출물 목록 등을 명시한 제안서를 작성하세요.', + 'pro_tip' => '\'제외 범위(Out of Scope)\'를 명확히 적으세요. 나중에 딴소리 듣지 않으려면요.', + ], + [ + 'id' => 'prop_4', + 'title' => '제안 발표 (PT)', + 'detail' => '고객에게 제안 내용을 설명하고 우리가 가장 적임자임을 설득하세요.', + 'pro_tip' => '발표 자료는 \'고객의 언어\'로 작성하세요. 기술 용어 남발은 금물입니다.', + ], + ], + ], + [ + 'id' => 5, + 'title' => '조율 및 협상', + 'subtitle' => 'Negotiation', + 'icon' => 'scale', + 'color' => 'orange', + 'bg_class' => 'bg-orange-100', + 'text_class' => 'text-orange-600', + 'description' => '제안 내용을 바탕으로 고객과 범위, 일정, 비용을 최종 조율하는 단계입니다.', + 'tips' => '협상은 이기는 게 아니라, 같이 갈 수 있는 길을 찾는 것입니다.', + 'checkpoints' => [ + [ + 'id' => 'nego_m_1', + 'title' => '범위 및 일정 조정', + 'detail' => '예산이나 일정에 맞춰 기능을 가감하거나 단계별 오픈 전략을 협의하세요.', + 'pro_tip' => '무리한 일정 단축은 단호하게 거절하되, \'선오픈\'과 같은 대안을 제시하세요.', + ], + [ + 'id' => 'nego_m_2', + 'title' => '추가 요구사항 대응', + 'detail' => '제안 과정에서 나온 추가 요구사항에 대해 비용 청구 여부를 결정하세요.', + 'pro_tip' => '서비스로 해주더라도 \'원래 얼마짜리인데 이번만 하는 것\'이라고 인지시키세요.', + ], + [ + 'id' => 'nego_m_3', + 'title' => 'R&R 명확화', + 'detail' => '우리 회사와 고객사가 각각 해야 할 역할을 명문화하세요.', + 'pro_tip' => '프로젝트 지연의 절반은 고객의 자료 전달 지연입니다. 숙제를 명확히 알려주세요.', + ], + [ + 'id' => 'nego_m_4', + 'title' => '최종 합의 도출', + 'detail' => '모든 쟁점 사항을 정리하고 최종 합의된 내용을 문서로 남기세요.', + 'pro_tip' => '구두 합의는 힘이 없습니다. 반드시 이메일이나 회의록으로 남기세요.', + ], + ], + ], + [ + 'id' => 6, + 'title' => '착수 및 계약', + 'subtitle' => 'Kickoff', + 'icon' => 'flag', + 'color' => 'green', + 'bg_class' => 'bg-green-100', + 'text_class' => 'text-green-600', + 'description' => '계약을 체결하고 프로젝트를 공식적으로 시작하는 단계입니다.', + 'tips' => '시작이 좋아야 끝도 좋습니다. 룰을 명확히 세우세요.', + 'checkpoints' => [ + [ + 'id' => 'kick_1', + 'title' => '계약서 검토 및 날인', + 'detail' => '과업지시서, 기술협약서 등 계약 부속 서류를 꼼꼼히 챙기세요.', + 'pro_tip' => '계약서에 \'검수 조건\'을 명확히 넣으세요. 실현 가능한 조건이어야 합니다.', + ], + [ + 'id' => 'kick_2', + 'title' => '프로젝트 팀 구성', + 'detail' => '수행 인력을 확정하고 내부 킥오프를 진행하세요.', + 'pro_tip' => '팀원들에게 프로젝트 배경뿐만 아니라 \'고객의 성향\'도 공유해 주세요.', + ], + [ + 'id' => 'kick_3', + 'title' => '착수 보고회 (Kick-off)', + 'detail' => '전원이 모여 프로젝트의 목표, 일정, 커뮤니케이션 룰을 공유하세요.', + 'pro_tip' => '첫인상이 전문적이어야 프로젝트가 순탄합니다. 깔끔하게 준비하세요.', + ], + [ + 'id' => 'kick_4', + 'title' => '협업 도구 세팅', + 'detail' => 'Jira, Slack 등 협업 도구를 세팅하고 고객을 초대하세요.', + 'pro_tip' => '소통 채널 단일화가 성공의 열쇠입니다. 간단 가이드를 제공하세요.', + ], + ], + ], + ], + + /* + |-------------------------------------------------------------------------- + | 아이콘 매핑 (Heroicons SVG) + |-------------------------------------------------------------------------- + */ + 'icons' => [ + 'search' => '', + 'phone' => '', + 'clipboard-check' => '', + 'presentation' => '', + 'scale' => '', + 'file-signature' => '', + 'arrow-right-left' => '', + 'code' => '', + 'file-text' => '', + 'flag' => '', + ], +]; diff --git a/database/seeders/BarobillPricingPolicySeeder.php b/database/seeders/BarobillPricingPolicySeeder.php index 7ba8202f..33324568 100644 --- a/database/seeders/BarobillPricingPolicySeeder.php +++ b/database/seeders/BarobillPricingPolicySeeder.php @@ -49,6 +49,30 @@ public function run(): void 'is_active' => true, 'sort_order' => 3, ], + [ + 'service_type' => 'hometax_purchase', + 'name' => '홈택스 매입', + 'description' => '월정액 33,000원(VAT포함) - 코드브릿지엑스 지원으로 무료 제공', + 'free_quota' => 0, + 'free_quota_unit' => '월정액', + 'additional_unit' => 0, + 'additional_unit_label' => '-', + 'additional_price' => 0, + 'is_active' => true, + 'sort_order' => 4, + ], + [ + 'service_type' => 'hometax_sales', + 'name' => '홈택스 매출', + 'description' => '월정액 33,000원(VAT포함) - 코드브릿지엑스 지원으로 무료 제공', + 'free_quota' => 0, + 'free_quota_unit' => '월정액', + 'additional_unit' => 0, + 'additional_unit_label' => '-', + 'additional_price' => 0, + 'is_active' => true, + 'sort_order' => 5, + ], ]; foreach ($policies as $policy) { diff --git a/resources/views/barobill/etax/index.blade.php b/resources/views/barobill/etax/index.blade.php index 116d2fe8..43025df3 100644 --- a/resources/views/barobill/etax/index.blade.php +++ b/resources/views/barobill/etax/index.blade.php @@ -191,83 +191,159 @@ }; return ( -
-
-
- - + + {/* 공급자 정보 */} +
+

공급자 정보

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
-
- - -
-
- - -
-
- - -
-
- - setFormData({ ...formData, recipientBizno: e.target.value })} required /> -
-
- - setFormData({ ...formData, recipientName: e.target.value })} required /> -
-
- - setFormData({ ...formData, recipientCeo: e.target.value })} /> -
-
- - setFormData({ ...formData, recipientAddr: e.target.value })} required /> -
-
- - setFormData({ ...formData, recipientEmail: e.target.value })} required /> -
-
- - setFormData({ ...formData, supplyDate: e.target.value })} required /> +
+ + {/* 공급받는자 정보 */} +
+

공급받는자 정보

+
+
+ + setFormData({ ...formData, recipientBizno: e.target.value })} required /> +
+
+ + setFormData({ ...formData, recipientName: e.target.value })} required /> +
+
+ + setFormData({ ...formData, recipientCeo: e.target.value })} /> +
+
+ + setFormData({ ...formData, recipientAddr: e.target.value })} required /> +
+
+ + setFormData({ ...formData, recipientContact: e.target.value })} /> +
+
+ + setFormData({ ...formData, recipientEmail: e.target.value })} required /> +
+
+ + setFormData({ ...formData, supplyDate: e.target.value })} required /> +
-
+
- +
-
- {formData.items.map((item, index) => ( -
-
- handleItemChange(index, 'name', e.target.value)} required /> -
-
- handleItemChange(index, 'qty', parseFloat(e.target.value) || 0)} min="0" step="0.01" required /> -
-
- handleItemChange(index, 'unitPrice', parseFloat(e.target.value) || 0)} min="0" required /> -
-
- -
-
- {formData.items.length > 1 && ( - - )} -
-
- ))} +
+ + + + + + + + + + + + + + + {formData.items.map((item, index) => { + const supplyAmt = item.qty * item.unitPrice; + const vat = item.vatType === 'vat' ? Math.floor(supplyAmt * 0.1) : 0; + const total = supplyAmt + vat; + + // 콤마 형식으로 숫자 표시 + const formatWithComma = (num) => num ? num.toLocaleString() : ''; + // 콤마 제거하고 숫자로 변환 + const parseNumber = (str) => parseFloat(str.replace(/,/g, '')) || 0; + + return ( + + + + + + + + + + + ); + })} + + + + + + + + + + +
품목명수량단가공급가액세액금액과세
+ handleItemChange(index, 'name', e.target.value)} required /> + + handleItemChange(index, 'qty', parseNumber(e.target.value))} required /> + + handleItemChange(index, 'unitPrice', parseNumber(e.target.value))} required /> + + {supplyAmt.toLocaleString()} + + {vat.toLocaleString()} + + {total.toLocaleString()} + + + + {formData.items.length > 1 && ( + + )} +
합계 + {formData.items.reduce((sum, item) => sum + (item.qty * item.unitPrice), 0).toLocaleString()} + + {formData.items.reduce((sum, item) => { + const supplyAmt = item.qty * item.unitPrice; + return sum + (item.vatType === 'vat' ? Math.floor(supplyAmt * 0.1) : 0); + }, 0).toLocaleString()} + + {formData.items.reduce((sum, item) => { + const supplyAmt = item.qty * item.unitPrice; + const vat = item.vatType === 'vat' ? Math.floor(supplyAmt * 0.1) : 0; + return sum + supplyAmt + vat; + }, 0).toLocaleString()} +
@@ -297,7 +373,7 @@ }; // InvoiceList Component - const InvoiceList = ({ invoices, onViewDetail, onCheckStatus, onDelete, dateFrom, dateTo, onDateFromChange, onDateToChange, onThisMonth, onLastMonth, totalCount }) => { + const InvoiceList = ({ invoices, onViewDetail, onCheckStatus, onDelete, dateFrom, dateTo, onDateFromChange, onDateToChange, onThisMonth, onLastMonth, totalCount, sortColumn, sortDirection, onSort }) => { const formatCurrency = (val) => new Intl.NumberFormat('ko-KR', { style: 'currency', currency: 'KRW' }).format(val); const formatDate = (date) => new Date(date).toLocaleDateString('ko-KR'); @@ -312,6 +388,39 @@ return {config.label}; }; + // 정렬 아이콘 + const SortIcon = ({ column }) => { + if (sortColumn !== column) { + return ( + + + + ); + } + return sortDirection === 'asc' ? ( + + + + ) : ( + + + + ); + }; + + // 정렬 가능한 헤더 컴포넌트 + const SortableHeader = ({ column, children, className = '' }) => ( + onSort(column)} + > +
+ {children} + +
+ + ); + return (
@@ -363,8 +472,9 @@ className="px-3 py-1.5 text-sm bg-stone-100 text-stone-600 rounded-lg hover:bg-s 발행번호 - 공급받는자 - 공급일자 + 공급받는자 + 작성일자 + 전송일자 공급가액 부가세 합계 @@ -374,13 +484,14 @@ className="px-3 py-1.5 text-sm bg-stone-100 text-stone-600 rounded-lg hover:bg-s {invoices.length === 0 ? ( - 해당 기간에 발행된 세금계산서가 없습니다. + 해당 기간에 발행된 세금계산서가 없습니다. ) : ( invoices.map((invoice) => ( onViewDetail(invoice)}> {invoice.issueKey || invoice.id} {invoice.recipientName} {formatDate(invoice.supplyDate)} + {invoice.sentAt ? formatDate(invoice.sentAt) : -} {formatCurrency(invoice.totalSupplyAmt)} {formatCurrency(invoice.totalVat)} {formatCurrency(invoice.total)} @@ -436,9 +547,15 @@ className="px-3 py-1.5 text-sm bg-stone-100 text-stone-600 rounded-lg hover:bg-s
{invoice.recipientBizno}
-
- -
{formatDate(invoice.supplyDate)}
+
+
+ +
{formatDate(invoice.supplyDate)}
+
+
+ +
{invoice.sentAt ? formatDate(invoice.sentAt) : 미전송}
+
@@ -555,6 +672,10 @@ className="px-3 py-1.5 text-sm bg-stone-100 text-stone-600 rounded-lg hover:bg-s const [dateFrom, setDateFrom] = useState(currentMonth.from); const [dateTo, setDateTo] = useState(currentMonth.to); + // 정렬 상태 (기본: 작성일자 내림차순) + const [sortColumn, setSortColumn] = useState('supplyDate'); + const [sortDirection, setSortDirection] = useState('desc'); + useEffect(() => { loadInvoices(); }, []); @@ -648,6 +769,18 @@ className="px-3 py-1.5 text-sm bg-stone-100 text-stone-600 rounded-lg hover:bg-s ); } + // 정렬 핸들러 + const handleSort = (column) => { + if (sortColumn === column) { + // 같은 컬럼 클릭 시 정렬 방향 토글 + setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc'); + } else { + // 다른 컬럼 클릭 시 해당 컬럼으로 변경, 내림차순 기본 + setSortColumn(column); + setSortDirection('desc'); + } + }; + // 날짜 필터 적용된 송장 목록 const filteredInvoices = invoices.filter(invoice => { const supplyDate = invoice.supplyDate; @@ -655,6 +788,27 @@ className="px-3 py-1.5 text-sm bg-stone-100 text-stone-600 rounded-lg hover:bg-s return supplyDate >= dateFrom && supplyDate <= dateTo; }); + // 정렬 적용 + const sortedInvoices = [...filteredInvoices].sort((a, b) => { + let aVal = a[sortColumn]; + let bVal = b[sortColumn]; + + // null/undefined 처리 + if (aVal == null) aVal = ''; + if (bVal == null) bVal = ''; + + // 문자열 비교 + if (typeof aVal === 'string' && typeof bVal === 'string') { + const comparison = aVal.localeCompare(bVal, 'ko-KR'); + return sortDirection === 'asc' ? comparison : -comparison; + } + + // 숫자 비교 + if (aVal < bVal) return sortDirection === 'asc' ? -1 : 1; + if (aVal > bVal) return sortDirection === 'asc' ? 1 : -1; + return 0; + }); + // 이번 달 버튼 const handleThisMonth = () => { const dates = getMonthDates(0); @@ -723,7 +877,7 @@ className="px-3 py-1.5 text-sm bg-stone-100 text-stone-600 rounded-lg hover:bg-s {/* Invoice List */}
- - +
@@ -73,7 +66,7 @@ class="flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900 transition-colors" title="{{ $menu->name }}"> - {{ $menu->name }} + {{ $menu->name }} @endforeach @@ -96,7 +89,6 @@ class="flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-gray-600 hover: @endforeach -
@@ -120,17 +112,7 @@ class="flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-gray-600 hover: R&D Labs
- -
- - -
- - +
    @@ -163,7 +145,6 @@ class="block px-2 py-1 text-xs text-gray-600 rounded hover:bg-gray-100 hover:tex @endforeach
-
diff --git a/resources/views/components/sidebar/menu-group.blade.php b/resources/views/components/sidebar/menu-group.blade.php index d24ad0d3..f0c42cfa 100644 --- a/resources/views/components/sidebar/menu-group.blade.php +++ b/resources/views/components/sidebar/menu-group.blade.php @@ -17,7 +17,7 @@
  • +
  • +
    + + +
    +
    +
    +
    +

    총 조회 건수

    +

    {{ number_format($usageData['total_count']) }}건

    +
    +
    + + + +
    +
    +
    + +
    +
    +
    +

    유료 조회 건수

    +

    + {{ number_format(max(0, $usageData['total_count'] - (count($usageData['details']) * $policy['free_quota']))) }}건 +

    +
    +
    + + + +
    +
    +
    + +
    +
    +
    +

    예상 청구 금액

    +

    {{ number_format($usageData['total_fee']) }}원

    +
    +
    + + + +
    +
    +
    +
    + + +
    +
    +

    + @if($isHQ) + 테넌트별 조회 현황 + @else + 월별 조회 현황 + @endif +

    +
    + + @if(count($usageData['details']) > 0) +
    + + + + @if($isHQ) + + @endif + @if($filters['view_type'] === 'yearly' || !$isHQ) + + @endif + + + + + + + + @foreach($usageData['details'] as $row) + + @if($isHQ) + + @endif + @if($filters['view_type'] === 'yearly' || !$isHQ) + + @endif + + + + + + @endforeach + + + + + + + + + + +
    테넌트기간총 조회무료 ({{ $policy['free_quota'] }}건)유료요금
    +
    {{ $row['tenant_name'] }}
    + @if(isset($row['tenant_code'])) +
    {{ $row['tenant_code'] }}
    + @endif +
    + {{ $row['month'] ?? '-' }} + + {{ number_format($row['count']) }}건 + + {{ number_format($row['free_count']) }}건 + + {{ number_format($row['paid_count']) }}건 + + {{ number_format($row['fee']) }}원 +
    + 합계 + {{ number_format($usageData['total_count']) }}건 + {{ number_format(array_sum(array_column($usageData['details'], 'free_count'))) }}건 + + {{ number_format(array_sum(array_column($usageData['details'], 'paid_count'))) }}건 + {{ number_format($usageData['total_fee']) }}원
    +
    + @else +
    + + + +

    조회 내역이 없습니다.

    +
    + @endif +
    +
    + +@push('scripts') + +@endpush +@endsection diff --git a/resources/views/finance/sales-commission.blade.php b/resources/views/finance/sales-commission.blade.php index 6e8beb52..9e3e2ed3 100644 --- a/resources/views/finance/sales-commission.blade.php +++ b/resources/views/finance/sales-commission.blade.php @@ -120,7 +120,7 @@ function SalesCommissionManagement() { const handleDelete = (id) => { if (confirm('정말 삭제하시겠습니까?')) { setCommissions(prev => prev.filter(item => item.id !== id)); setShowModal(false); } }; const handleDownload = () => { - const rows = [['영업수수료', `${dateRange.start} ~ ${dateRange.end}`], [], ['날짜', '영업담당', '고객사', '프로젝트', '매출액', '수수료율', '수수료', '상태'], + const rows = [['영업수수료', `${dateRange.start} ~ ${dateRange.end}`], [], ['날짜', '영업파트너', '고객사', '프로젝트', '매출액', '수수료율', '수수료', '상태'], ...filteredCommissions.map(item => [item.date, item.salesperson, item.customer, item.project, item.salesAmount, `${item.rate}%`, item.commission, item.status === 'paid' ? '지급완료' : '지급예정'])]; const csvContent = rows.map(row => row.join(',')).join('\n'); const blob = new Blob(['\uFEFF' + csvContent], { type: 'text/csv;charset=utf-8;' }); @@ -188,7 +188,7 @@ function SalesCommissionManagement() { 날짜 - 영업담당 + 영업파트너 고객사 프로젝트 매출액 @@ -231,7 +231,7 @@ function SalesCommissionManagement() {
    setFormData(prev => ({ ...prev, date: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg" />
    -
    +
    setFormData(prev => ({ ...prev, customer: e.target.value }))} placeholder="고객사명" className="w-full px-3 py-2 border border-gray-300 rounded-lg" />
    setFormData(prev => ({ ...prev, project: e.target.value }))} placeholder="프로젝트명" className="w-full px-3 py-2 border border-gray-300 rounded-lg" />
    diff --git a/resources/views/finance/sales-commission/index.blade.php b/resources/views/finance/sales-commission/index.blade.php new file mode 100644 index 00000000..91faae57 --- /dev/null +++ b/resources/views/finance/sales-commission/index.blade.php @@ -0,0 +1,376 @@ +@extends('layouts.app') + +@section('title', '영업수수료정산') + +@section('content') +
    + {{-- 페이지 헤더 --}} +
    +
    +

    영업수수료정산

    +

    {{ $year }}년 {{ $month }}월 지급예정

    +
    +
    + + + + + + 엑셀 다운로드 + +
    +
    + + {{-- 통계 카드 --}} +
    + @include('finance.sales-commission.partials.stats-cards', ['stats' => $stats, 'year' => $year, 'month' => $month]) +
    + + {{-- 필터 섹션 --}} +
    +
    +
    + {{-- 년/월 선택 --}} +
    + + +
    +
    + + +
    + + {{-- 상태 필터 --}} +
    + + +
    + + {{-- 입금구분 필터 --}} +
    + + +
    + + {{-- 영업파트너 필터 --}} +
    + + +
    + + {{-- 버튼 --}} +
    + + + 초기화 + +
    +
    +
    +
    + + {{-- 일괄 처리 버튼 --}} + + + {{-- 정산 테이블 --}} +
    + @include('finance.sales-commission.partials.commission-table', ['commissions' => $commissions]) +
    +
    + +{{-- 입금 등록 모달 --}} + + +{{-- 상세 모달 --}} + +@endsection + +@push('scripts') + +@endpush diff --git a/resources/views/finance/sales-commission/partials/commission-table.blade.php b/resources/views/finance/sales-commission/partials/commission-table.blade.php new file mode 100644 index 00000000..ea6f0cba --- /dev/null +++ b/resources/views/finance/sales-commission/partials/commission-table.blade.php @@ -0,0 +1,138 @@ +{{-- 정산 테이블 --}} +
    +
    + + + + + + + + + + + + + + + + + + + @forelse ($commissions as $commission) + + + + + + + + + + + + + + + @empty + + + + @endforelse + +
    + + 테넌트입금구분입금액입금일영업파트너파트너수당매니저매니저수당지급예정일상태액션
    + @if (in_array($commission->status, ['pending', 'approved'])) + + @endif + +
    {{ $commission->tenant->name ?? $commission->tenant->company_name ?? '-' }}
    +
    ID: {{ $commission->tenant_id }}
    +
    + + {{ $commission->payment_type_label }} + + + {{ number_format($commission->payment_amount) }} + + {{ $commission->payment_date->format('Y-m-d') }} + +
    {{ $commission->partner?->user?->name ?? '-' }}
    +
    {{ $commission->partner_rate }}%
    +
    + {{ number_format($commission->partner_commission) }} + +
    {{ $commission->manager?->name ?? '-' }}
    +
    {{ $commission->manager_rate }}%
    +
    + {{ number_format($commission->manager_commission) }} + + {{ $commission->scheduled_payment_date->format('Y-m-d') }} + + @php + $statusColors = [ + 'pending' => 'bg-yellow-100 text-yellow-800', + 'approved' => 'bg-blue-100 text-blue-800', + 'paid' => 'bg-green-100 text-green-800', + 'cancelled' => 'bg-red-100 text-red-800', + ]; + @endphp + + {{ $commission->status_label }} + + +
    + + @if ($commission->status === 'pending') + + + @elseif ($commission->status === 'approved') + + @endif +
    +
    + 등록된 정산 내역이 없습니다. +
    +
    + + {{-- 페이지네이션 --}} + @if ($commissions->hasPages()) +
    + {{ $commissions->links() }} +
    + @endif +
    diff --git a/resources/views/finance/sales-commission/partials/detail-modal.blade.php b/resources/views/finance/sales-commission/partials/detail-modal.blade.php new file mode 100644 index 00000000..4437755e --- /dev/null +++ b/resources/views/finance/sales-commission/partials/detail-modal.blade.php @@ -0,0 +1,196 @@ +{{-- 정산 상세 모달 --}} +
    +
    +

    정산 상세

    + +
    +
    + +@if ($commission) +
    + {{-- 기본 정보 --}} +
    +
    +

    테넌트

    +

    {{ $commission->tenant->name ?? $commission->tenant->company_name ?? '-' }}

    +
    +
    +

    상태

    + @php + $statusColors = [ + 'pending' => 'bg-yellow-100 text-yellow-800', + 'approved' => 'bg-blue-100 text-blue-800', + 'paid' => 'bg-green-100 text-green-800', + 'cancelled' => 'bg-red-100 text-red-800', + ]; + @endphp + + {{ $commission->status_label }} + +
    +
    +

    입금 구분

    + + {{ $commission->payment_type_label }} + +
    +
    +

    입금일

    +

    {{ $commission->payment_date->format('Y-m-d') }}

    +
    +
    + + {{-- 금액 정보 --}} +
    +

    금액 정보

    +
    +
    + 입금액 + {{ number_format($commission->payment_amount) }}원 +
    +
    + 수당 기준액 (가입비 50%) + {{ number_format($commission->base_amount) }}원 +
    +
    +
    + + {{-- 수당 정보 --}} +
    +

    수당 정보

    +
    +
    +
    + 영업파트너 + {{ $commission->partner?->user?->name ?? '-' }} +
    +
    + {{ $commission->partner_rate }}% + {{ number_format($commission->partner_commission) }}원 +
    +
    +
    +
    + 매니저 + {{ $commission->manager?->name ?? '-' }} +
    +
    + {{ $commission->manager_rate }}% + {{ number_format($commission->manager_commission) }}원 +
    +
    +
    + 총 수당 + {{ number_format($commission->total_commission) }}원 +
    +
    +
    + + {{-- 지급 일정 --}} +
    +
    +

    지급예정일

    +

    {{ $commission->scheduled_payment_date->format('Y-m-d') }}

    +
    +
    +

    실제지급일

    +

    {{ $commission->actual_payment_date?->format('Y-m-d') ?? '-' }}

    +
    +
    + + {{-- 승인 정보 --}} + @if ($commission->approved_at) +
    +
    +

    승인자

    +

    {{ $commission->approver?->name ?? '-' }}

    +
    +
    +

    승인일시

    +

    {{ $commission->approved_at->format('Y-m-d H:i') }}

    +
    +
    + @endif + + {{-- 이체 참조번호 --}} + @if ($commission->bank_reference) +
    +

    이체 참조번호

    +

    {{ $commission->bank_reference }}

    +
    + @endif + + {{-- 메모 --}} + @if ($commission->notes) +
    +

    메모

    +

    {{ $commission->notes }}

    +
    + @endif + + {{-- 상품별 상세 내역 --}} + @if ($commission->details->count() > 0) +
    +

    상품별 수당 내역

    +
    + + + + + + + + + + + @foreach ($commission->details as $detail) + + + + + + + @endforeach + +
    상품가입비파트너수당매니저수당
    {{ $detail->contractProduct?->product?->name ?? '-' }}{{ number_format($detail->registration_fee) }}원{{ number_format($detail->partner_commission) }}원{{ number_format($detail->manager_commission) }}원
    +
    +
    + @endif + + {{-- 액션 버튼 --}} +
    + @if ($commission->status === 'pending') + + + @elseif ($commission->status === 'approved') + + @endif + +
    +
    +@else +
    + 정산 정보를 찾을 수 없습니다. +
    +@endif diff --git a/resources/views/finance/sales-commission/partials/payment-form.blade.php b/resources/views/finance/sales-commission/partials/payment-form.blade.php new file mode 100644 index 00000000..ae0f33f7 --- /dev/null +++ b/resources/views/finance/sales-commission/partials/payment-form.blade.php @@ -0,0 +1,185 @@ +{{-- 입금 등록 폼 --}} +
    + @csrf + + {{-- 테넌트 선택 --}} +
    + + @if ($management) + +
    +
    {{ $management->tenant->name ?? $management->tenant->company_name }}
    +
    영업파트너: {{ $management->salesPartner?->user?->name ?? '-' }}
    +
    + @else + + @endif +
    + + @if ($management) + {{-- 계약 상품 정보 --}} + @if ($management->contractProducts->count() > 0) +
    + +
    + + + + + + + + + @foreach ($management->contractProducts as $product) + + + + + @endforeach + + + + + + + +
    상품명가입비
    {{ $product->product?->name ?? '-' }}{{ number_format($product->registration_fee ?? 0) }}원
    총 가입비 + {{ number_format($management->contractProducts->sum('registration_fee')) }}원 +
    +
    +
    + @endif + + {{-- 현재 입금 상태 --}} +
    +
    +
    + 계약금 +
    + {{ $management->deposit_status === 'paid' ? '입금완료' : '대기' }} + @if ($management->deposit_amount) + ({{ number_format($management->deposit_amount) }}원) + @endif +
    +
    +
    + 잔금 +
    + {{ $management->balance_status === 'paid' ? '입금완료' : '대기' }} + @if ($management->balance_amount) + ({{ number_format($management->balance_amount) }}원) + @endif +
    +
    +
    +
    + @endif + + {{-- 입금 구분 --}} +
    + +
    + + +
    +
    + + {{-- 입금액 --}} +
    + +
    + + +
    +

    총 가입비의 50%를 입금받습니다.

    +
    + + {{-- 입금일 --}} +
    + + +
    + + {{-- 수당 미리보기 --}} + @if ($management && $management->salesPartner) + @php + $totalFee = $management->contractProducts->sum('registration_fee') ?: 0; + $baseAmount = $totalFee / 2; + $partnerRate = $management->salesPartner->commission_rate ?? 20; + $managerRate = $management->salesPartner->manager_commission_rate ?? 5; + $partnerCommission = $baseAmount * ($partnerRate / 100); + $managerCommission = $management->manager_user_id ? $baseAmount * ($managerRate / 100) : 0; + @endphp +
    +

    수당 미리보기

    +
    +
    + 기준액 (가입비의 50%) + {{ number_format($baseAmount) }}원 +
    +
    + 영업파트너 수당 ({{ $partnerRate }}%) + {{ number_format($partnerCommission) }}원 +
    +
    + 매니저 수당 ({{ $managerRate }}%) + {{ number_format($managerCommission) }}원 +
    +
    + 총 수당 + {{ number_format($partnerCommission + $managerCommission) }}원 +
    +
    +
    + @endif + + {{-- 버튼 --}} +
    + + +
    +
    diff --git a/resources/views/finance/sales-commission/partials/stats-cards.blade.php b/resources/views/finance/sales-commission/partials/stats-cards.blade.php new file mode 100644 index 00000000..7c5c7818 --- /dev/null +++ b/resources/views/finance/sales-commission/partials/stats-cards.blade.php @@ -0,0 +1,70 @@ +{{-- 통계 카드 --}} +
    + {{-- 지급 대기 --}} +
    +
    +
    +

    지급 대기

    +

    {{ number_format($stats['pending']['partner_total'] + $stats['pending']['manager_total']) }}원

    +
    +
    + + + +
    +
    +

    {{ $stats['pending']['count'] }}건

    +
    + + {{-- 승인 완료 --}} +
    +
    +
    +

    승인 완료

    +

    {{ number_format($stats['approved']['partner_total'] + $stats['approved']['manager_total']) }}원

    +
    +
    + + + +
    +
    +

    {{ $stats['approved']['count'] }}건

    +
    + + {{-- 지급 완료 --}} +
    +
    +
    +

    지급 완료

    +

    {{ number_format($stats['paid']['partner_total'] + $stats['paid']['manager_total']) }}원

    +
    +
    + + + +
    +
    +

    {{ $stats['paid']['count'] }}건

    +
    + + {{-- 전체 합계 --}} +
    +
    +
    +

    {{ $year }}년 {{ $month }}월 총 수당

    +

    {{ number_format($stats['total']['partner_commission'] + $stats['total']['manager_commission']) }}원

    +
    +
    + + + +
    +
    +
    + 파트너: {{ number_format($stats['total']['partner_commission']) }}원 + | + 매니저: {{ number_format($stats['total']['manager_commission']) }}원 +
    +
    +
    diff --git a/resources/views/lab/ai/meeting-summary.blade.php b/resources/views/lab/ai/meeting-summary.blade.php deleted file mode 100644 index 1d0a43ca..00000000 --- a/resources/views/lab/ai/meeting-summary.blade.php +++ /dev/null @@ -1,432 +0,0 @@ -@extends('layouts.app') - -@section('title', '회의록 AI 요약') - -@push('styles') - -@endpush - -@section('content') -
    - {{-- 헤더 --}} -
    -

    회의록 AI 요약

    -

    회의 녹음 파일을 업로드하면 AI가 자동으로 회의록을 작성합니다

    -
    - - {{-- 업로드 섹션 --}} -
    -
    - @csrf - {{-- 제목 입력 --}} -
    - - -
    - - {{-- 파일 드롭존 --}} -
    - - - -

    파일을 드래그하거나 클릭하여 업로드

    -

    지원 형식: WebM, WAV, MP3, OGG, M4A, MP4 (최대 100MB)

    - -
    - - {{-- 선택된 파일 정보 --}} - - - {{-- 업로드 버튼 --}} - -
    -
    - - {{-- 처리 중 상태 --}} - - - {{-- 결과 섹션 --}} - - - {{-- 최근 회의록 목록 --}} -
    -

    - - - - 최근 회의록 -

    - -
    -
    -
    -
    -
    -
    -
    -@endsection - -@push('scripts') - - -{{-- Markdown 파서 --}} - -@endpush diff --git a/resources/views/lab/ai/operator-chatbot.blade.php b/resources/views/lab/ai/operator-chatbot.blade.php deleted file mode 100644 index 0ba5b83e..00000000 --- a/resources/views/lab/ai/operator-chatbot.blade.php +++ /dev/null @@ -1,62 +0,0 @@ -@extends('layouts.app') - -@section('title', '운영자용 챗봇') - -@push('styles') - -@endpush - -@section('content') -
    -
    -
    - - - -

    운영자용 챗봇

    -

    - SAM 시스템 운영에 필요한 질문에 AI가 답변하고 - 운영 매뉴얼과 문서를 기반으로 지원합니다. -

    -
    AI/Automation
    -
    - -
    -
    -

    - - - - - 예정 기능 -

    -
    -
    -

    운영 지원

    -
      -
    • • 시스템 운영 Q&A
    • -
    • • 장애 대응 가이드
    • -
    • • 설정 변경 안내
    • -
    -
    -
    -

    지식 기반

    -
      -
    • • 운영 매뉴얼 RAG
    • -
    • • 과거 이슈 검색
    • -
    • • 베스트 프랙티스 제안
    • -
    -
    -
    -
    -
    -
    -
    -@endsection diff --git a/resources/views/lab/ai/tenant-chatbot.blade.php b/resources/views/lab/ai/tenant-chatbot.blade.php deleted file mode 100644 index 66a30488..00000000 --- a/resources/views/lab/ai/tenant-chatbot.blade.php +++ /dev/null @@ -1,62 +0,0 @@ -@extends('layouts.app') - -@section('title', '테넌트 챗봇') - -@push('styles') - -@endpush - -@section('content') -
    -
    -
    - - - -

    테넌트 챗봇

    -

    - 각 테넌트의 업로드된 지식을 기반으로 동작하는 - 맞춤형 AI 챗봇 인터페이스입니다. -

    -
    AI/Automation
    -
    - -
    -
    -

    - - - - - 예정 기능 -

    -
    -
    -

    챗봇 기능

    -
      -
    • • 실시간 대화 인터페이스
    • -
    • • 대화 이력 저장
    • -
    • • 멀티턴 컨텍스트
    • -
    -
    -
    -

    테넌트 격리

    -
      -
    • • 지식베이스 분리
    • -
    • • 권한별 접근 제어
    • -
    • • 사용량 모니터링
    • -
    -
    -
    -
    -
    -
    -
    -@endsection diff --git a/resources/views/lab/ai/tenant-knowledge.blade.php b/resources/views/lab/ai/tenant-knowledge.blade.php deleted file mode 100644 index 092c0c30..00000000 --- a/resources/views/lab/ai/tenant-knowledge.blade.php +++ /dev/null @@ -1,62 +0,0 @@ -@extends('layouts.app') - -@section('title', '테넌트 지식 업로드') - -@push('styles') - -@endpush - -@section('content') -
    -
    -
    - - - -

    테넌트 지식 업로드

    -

    - 각 테넌트가 자체 문서와 지식을 업로드하여 - 맞춤형 AI 챗봇을 구축할 수 있습니다. -

    -
    AI/Automation
    -
    - -
    -
    -

    - - - - - 예정 기능 -

    -
    -
    -

    문서 업로드

    -
      -
    • • PDF, Word, Excel 지원
    • -
    • • 웹페이지 크롤링
    • -
    • • Notion/Confluence 연동
    • -
    -
    -
    -

    지식 관리

    -
      -
    • • 자동 벡터 인덱싱
    • -
    • • 카테고리 분류
    • -
    • • 버전 관리
    • -
    -
    -
    -
    -
    -
    -
    -@endsection diff --git a/resources/views/lab/ai/vertex-rag.blade.php b/resources/views/lab/ai/vertex-rag.blade.php deleted file mode 100644 index d2949a91..00000000 --- a/resources/views/lab/ai/vertex-rag.blade.php +++ /dev/null @@ -1,62 +0,0 @@ -@extends('layouts.app') - -@section('title', 'Vertex RAG 챗봇') - -@push('styles') - -@endpush - -@section('content') -
    -
    -
    - - - -

    Vertex RAG 챗봇

    -

    - Google Vertex AI를 활용한 RAG(Retrieval-Augmented Generation) - 기반의 지능형 챗봇 시스템입니다. -

    -
    AI/Automation
    -
    - -
    -
    -

    - - - - - 예정 기능 -

    -
    -
    -

    Vertex AI 연동

    -
      -
    • • Gemini 1.5 Pro 모델
    • -
    • • Vector Search 활용
    • -
    • • 문서 임베딩 자동화
    • -
    -
    -
    -

    RAG 파이프라인

    -
      -
    • • 문서 청킹 전략
    • -
    • • 시맨틱 검색
    • -
    • • 출처 표시 응답
    • -
    -
    -
    -
    -
    -
    -
    -@endsection diff --git a/resources/views/lab/ai/web-recording.blade.php b/resources/views/lab/ai/web-recording.blade.php deleted file mode 100644 index 74e49f4a..00000000 --- a/resources/views/lab/ai/web-recording.blade.php +++ /dev/null @@ -1,553 +0,0 @@ -@extends('layouts.app') - -@section('title', '웹 녹음 AI 요약') - -@push('styles') - -@endpush - -@section('content') -
    - {{-- 헤더 --}} -
    -

    웹 녹음 AI 요약

    -

    브라우저에서 녹음하고 AI가 자동으로 회의록을 작성합니다

    -
    - - {{-- 녹음 섹션 --}} -
    -
    - {{-- 타이머 --}} -
    00:00
    - - {{-- 파형 (녹음 중에만 표시) --}} - - - {{-- 상태 표시 --}} -
    - 대기 중 -
    - - {{-- 녹음 버튼 --}} -
    - -
    - - {{-- 안내 텍스트 --}} -

    - 버튼을 클릭하여 녹음을 시작하세요 -

    -
    -
    - - {{-- 처리 중 상태 (숨김) --}} - - - {{-- 결과 섹션 (숨김) --}} - - - {{-- 최근 회의록 목록 --}} -
    -

    - - - - 최근 회의록 -

    - -
    -
    -
    -
    -
    -
    -
    -@endsection - -@push('scripts') - - -{{-- Markdown 파서 --}} - -@endpush diff --git a/resources/views/lab/ai/web-recording/partials/list.blade.php b/resources/views/lab/ai/web-recording/partials/list.blade.php deleted file mode 100644 index 7c918073..00000000 --- a/resources/views/lab/ai/web-recording/partials/list.blade.php +++ /dev/null @@ -1,60 +0,0 @@ -{{-- 회의록 목록 (HTMX partial) --}} -@forelse($meetings as $meeting) -
    -
    -
    -

    {{ $meeting->title }}

    -
    - {{ $meeting->user?->name ?? '알 수 없음' }} - | - {{ $meeting->created_at->format('Y-m-d H:i') }} - @if($meeting->duration_seconds) - | - {{ $meeting->formatted_duration }} - @endif -
    -
    -
    - @php - $statusClass = match($meeting->status) { - 'PENDING' => 'bg-yellow-100 text-yellow-700', - 'PROCESSING' => 'bg-blue-100 text-blue-700', - 'COMPLETED' => 'bg-green-100 text-green-700', - 'FAILED' => 'bg-red-100 text-red-700', - default => 'bg-gray-100 text-gray-700', - }; - @endphp - - {{ $meeting->status_label }} - - -
    -
    - @if($meeting->transcript_text) -

    - {{ Str::limit($meeting->transcript_text, 100) }} -

    - @endif -
    -@empty -
    - - - -

    저장된 회의록이 없습니다.

    -

    새 녹음을 시작해보세요!

    -
    -@endforelse - -{{-- 페이지네이션 --}} -@if($meetings->hasPages()) -
    - {{ $meetings->links() }} -
    -@endif diff --git a/resources/views/lab/ai/web-recording/partials/summary.blade.php b/resources/views/lab/ai/web-recording/partials/summary.blade.php deleted file mode 100644 index cf9b77bb..00000000 --- a/resources/views/lab/ai/web-recording/partials/summary.blade.php +++ /dev/null @@ -1,98 +0,0 @@ -{{-- 회의록 요약 결과 (HTMX partial) --}} -@if($meeting->isProcessing()) -
    -
    -

    회의록을 생성하고 있습니다...

    -

    음성 인식 및 AI 요약 중

    -
    -@elseif($meeting->isCompleted()) -
    - {{-- 제목 편집 --}} -
    - - -
    - - {{-- 메타 정보 --}} -
    - 작성자: {{ $meeting->user?->name ?? '알 수 없음' }} - | - 녹음 시간: {{ $meeting->formatted_duration }} - | - {{ $meeting->created_at->format('Y-m-d H:i') }} -
    - - {{-- 음성 인식 결과 --}} -
    -
    - 음성 인식 결과 - - - -
    -
    -
    -

    {{ $meeting->transcript_text ?: '음성 인식 결과가 없습니다.' }}

    -
    -
    -
    - - {{-- AI 요약 결과 --}} -
    -
    - AI 요약 - - - -
    -
    -
    - {!! nl2br(e($meeting->summary_text ?: 'AI 요약 결과가 없습니다.')) !!} -
    -
    -
    -
    -@else -
    - - - -

    회의록 처리에 실패했습니다.

    -

    다시 시도해주세요.

    -
    -@endif - - diff --git a/resources/views/lab/ai/work-memo-summary.blade.php b/resources/views/lab/ai/work-memo-summary.blade.php deleted file mode 100644 index 14bfca3b..00000000 --- a/resources/views/lab/ai/work-memo-summary.blade.php +++ /dev/null @@ -1,455 +0,0 @@ -@extends('layouts.app') - -@section('title', '업무협의록 AI 요약') - -@push('styles') - -@endpush - -@section('content') -
    - {{-- 헤더 --}} -
    -

    업무협의록 AI 요약

    -

    고객사 미팅 녹음을 업로드하면 AI가 업무 협의 내용을 정리합니다

    -
    - - {{-- 안내 박스 --}} -
    -
    - - - -
    -

    업무협의록 전용 AI 요약

    -

    고객 요구사항, 합의 사항, 후속 조치(To-Do) 등 업무 협의에 특화된 구조로 정리됩니다.

    -
    -
    -
    - - {{-- 업로드 섹션 --}} -
    -
    - @csrf - - - {{-- 제목 입력 --}} -
    - - -
    - - {{-- 파일 드롭존 --}} -
    - - - -

    파일을 드래그하거나 클릭하여 업로드

    -

    지원 형식: WebM, WAV, MP3, OGG, M4A, MP4 (최대 100MB)

    - -
    - - {{-- 선택된 파일 정보 --}} - - - {{-- 업로드 버튼 --}} - -
    -
    - - {{-- 처리 중 상태 --}} - - - {{-- 결과 섹션 --}} - - - {{-- 최근 협의록 목록 --}} -
    -

    - - - - 최근 협의록 -

    - -
    -
    -
    -
    -
    -
    -
    -@endsection - -@push('scripts') - - -{{-- Markdown 파서 --}} - -@endpush diff --git a/resources/views/lab/strategy/debt.blade.php b/resources/views/lab/strategy/debt.blade.php deleted file mode 100644 index e62c080a..00000000 --- a/resources/views/lab/strategy/debt.blade.php +++ /dev/null @@ -1,483 +0,0 @@ -@extends('layouts.app') - -@section('title', '장기적 채권추심 전략') - -@push('styles') - -@endpush - -@section('content') -
    - -
    -
    -

    장기적 채권추심 전략

    -

    SAM 프로젝트 - 중소기업 맞춤형 채권관리 시스템

    -
    -

    기획 · 디자인 · 백엔드 · 프론트엔드

    -

    2025년 채권추심 자동화 솔루션 - 중장기 발전 계획

    -
    -
    -
    - - -
    -
    -

    프로젝트 비전

    -
    -

    채권관리의 디지털 혁신

    -

    복잡한 채권추심 업무를 자동화하고, 효율적인 회수 관리를 지원하는 통합 플랫폼

    -
    -
    -

    핵심 목표

    -
    -

    채권 관리

    채권 등록부터 회수까지 전체 생애주기 관리

    -

    추심 활동

    전화, 문자, 우편 등 다양한 추심 활동 기록 및 자동화

    -

    법적 절차

    독촉장, 지급명령, 소송 등 법적 절차 관리

    -

    회수 관리

    회수 내역 기록 및 통계, 분석 리포트

    -
    -
    -
    -

    주요 타겟 사용자

    -
      -
    • 중소기업: 외상 매출 관리 및 채권 회수
    • -
    • 채권추심 대행사: 다수 채권 통합 관리
    • -
    • 법무법인: 법적 절차 진행 관리
    • -
    • 금융기관: 연체 채권 관리
    • -
    -
    -
    -
    - - -
    -
    -

    핵심 가치 제안

    -
    -

    자동화

    추심 활동 자동 스케줄링

    월 80시간 업무 절감

    -

    가시성

    채권 현황 및 회수율 실시간 모니터링

    대시보드 한눈에 확인

    -

    법규 준수

    공정채권추심법 자동 준수

    법적 리스크 최소화

    -

    접근성

    모바일 앱을 통한 현장 추심 기록

    언제 어디서나 업무

    -

    통합성

    회계, ERP 시스템 연동

    데이터 자동 동기화

    -

    보안

    채무자 정보 암호화 및 접근 제어

    개인정보보호법 준수

    -
    -
    -
    - - -
    -
    -

    기획: 기능 요구사항

    -
    -

    1. 채권 관리

    -
      -
    • 채권 등록 (채무자 정보, 채권 금액, 발생일)
    • -
    • 채권 상태 관리 (정상/연체/회수/손실)
    • -
    • 채무자 정보 관리 (연락처, 주소, 직장 정보)
    • -
    • 채권 분류 및 그룹 관리
    • -
    -
    -
    -

    2. 추심 활동 관리

    -
      -
    • 전화 추심 기록 (통화 내용, 약속 일자)
    • -
    • 문자 발송 및 이력 관리
    • -
    • 우편물 발송 기록 (내용증명, 독촉장)
    • -
    • 방문 추심 기록 (GPS 위치, 사진)
    • -
    • 자동 추심 스케줄링
    • -
    -
    -
    -

    3. 법적 절차 관리

    -
      -
    • 독촉장 자동 생성 및 발송
    • -
    • 지급명령 신청 관리
    • -
    • 소송 진행 현황 관리
    • -
    • 강제집행 절차 추적
    • -
    -
    -
    -

    4. 회수 관리

    -
      -
    • 회수금 입금 기록
    • -
    • 분할 상환 관리
    • -
    • 회수율 통계
    • -
    • 미회수 채권 분석
    • -
    -
    -
    -
    - - -
    -
    -

    기획: 사용자 시나리오

    -
    -

    시나리오 1: 신규 채권 등록 프로세스

    -
      -
    1. 채권 발생: 매출 미수금 발생
    2. -
    3. 채권 등록: 채무자 정보, 채권 금액, 발생일 입력
    4. -
    5. 초기 접촉: 자동 문자 발송 (결제 요청)
    6. -
    7. 추심 스케줄: 7일, 14일, 21일 후 추심 활동 자동 생성
    8. -
    9. 진행 추적: 각 단계별 활동 기록
    10. -
    -
    -
    -

    시나리오 2: 전화 추심 활동

    -
      -
    1. 추심 대상 확인: 오늘 추심할 채권 목록 조회
    2. -
    3. 전화 발신: 시스템 내 전화 기능 또는 외부 연동
    4. -
    5. 통화 기록: 통화 내용, 채무자 반응, 약속 일자 입력
    6. -
    7. 후속 조치: 약속일 알림 자동 설정
    8. -
    9. 이력 관리: 모든 통화 내역 자동 저장
    10. -
    -
    -
    -

    시나리오 3: 법적 절차 진행

    -
      -
    1. 절차 개시 결정: 임의 추심 실패 판단
    2. -
    3. 독촉장 발송: 시스템에서 독촉장 자동 생성
    4. -
    5. 지급명령 신청: 필요 서류 자동 작성
    6. -
    7. 법원 진행 추적: 각 단계별 진행 상황 기록
    8. -
    9. 강제집행: 집행 절차 관리 및 결과 기록
    10. -
    -
    -
    -
    - - -
    -
    -

    기획: 고급 기능

    -
    -

    모바일 앱

    현장 추심 기록, 사진 첨부, GPS 위치 기록

    -

    스마트 알림

    약속일 알림, 추심 예정일 알림

    -

    대시보드

    채권 현황, 회수율, 부서별 성과

    -

    문서 자동화

    독촉장, 내용증명 자동 생성

    -

    ERP 연동

    회계 시스템 자동 연동

    -

    AI 추천

    최적 추심 시간 및 방법 추천

    -
    -
    -

    컴플라이언스 기능

    -
      -
    • 공정채권추심법 준수: 추심 시간, 횟수 자동 검증
    • -
    • 개인정보보호법: 채무자 정보 암호화 및 접근 로그
    • -
    • 전자문서법: 문서 전자서명 및 보관
    • -
    • 감사 추적: 모든 추심 활동 이력 기록
    • -
    -
    -
    -
    - -
    -
    -

    디자인: UI/UX 전략

    -
    -

    디자인 철학

    -

    "복잡한 채권 업무를 간단하게, 직관적으로"

    -
    -
    -

    핵심 디자인 원칙

    -
    -

    사용자 중심

    추심 담당자가 쉽게 사용

    -

    빠른 접근

    핵심 기능 2클릭 이내

    -

    모바일 우선

    현장에서 즉시 기록

    -

    보안 강조

    개인정보 보호 시각화

    -
    -
    -
    -

    주요 화면 구성

    -
      -
    • 관리자 대시보드: 채권 현황, 회수율, 부서별 성과
    • -
    • 추심 담당자 대시보드: 오늘 할 일, 약속 일정, 미완료 건
    • -
    • 채권 관리: 채권 등록, 조회, 수정, 상태 변경
    • -
    • 추심 활동: 전화/문자/방문 기록, 일정 관리
    • -
    • 법적 절차: 독촉장, 지급명령, 소송 관리
    • -
    -
    -
    -
    - -
    -
    -

    백엔드: 시스템 아키텍처

    -
    -

    마이크로서비스 아키텍처

    -

    확장 가능하고 안정적인 채권관리 시스템

    -
    -
    -

    주요 서비스 구성

    -
    -

    채권 정보 서비스

    채권 및 채무자 정보 관리

    -

    추심 활동 서비스

    추심 활동 기록 및 스케줄링

    -

    법적 절차 서비스

    법적 절차 관리 및 문서 생성

    -

    회수 관리 서비스

    회수금 처리 및 통계

    -

    알림 서비스

    푸시, 이메일, SMS 발송

    -

    분석 서비스

    회수율 분석 및 리포트

    -
    -
    -
    -

    기술 스택

    - - - - - - - - -
    레이어기술목적
    ApplicationNode.js (NestJS) / Python (FastAPI)비즈니스 로직 처리
    DatabasePostgreSQL관계형 데이터 저장
    CacheRedis세션 및 임시 데이터 캐싱
    Message QueueRabbitMQ비동기 처리 (알림 발송 등)
    -
    -
    -
    - -
    -
    -

    백엔드: 데이터베이스 설계

    -
    -

    핵심 테이블 구조

    - - - - - - - - - - -
    테이블명주요 필드관계
    debtorsid, name, phone, address, company1:N debts
    debtsid, debtor_id, amount, due_date, statusN:1 debtors, 1:N activities
    collection_activitiesid, debt_id, type, date, content, resultN:1 debts
    legal_proceduresid, debt_id, type, filed_date, statusN:1 debts
    paymentsid, debt_id, amount, date, methodN:1 debts
    schedulesid, debt_id, due_date, type, completedN:1 debts
    -
    -
    -

    데이터 보안 전략

    -
      -
    • 암호화: 주민번호, 계좌번호 AES-256 암호화
    • -
    • 접근 제어: 역할 기반 접근 제어 (RBAC)
    • -
    • 감사 로그: 모든 채권 및 추심 활동 이력 기록
    • -
    • 백업: 일일 자동 백업 및 복구 테스트
    • -
    -
    -
    -
    - -
    -
    -

    프론트엔드: 기술 스택

    -
    -

    현대적인 프론트엔드 아키텍처

    -

    React 18 + TypeScript 기반 SPA & 모바일 앱

    -
    -
    -

    웹 애플리케이션

    - - - - - - - - -
    카테고리기술목적
    프레임워크React 18 + TypeScript컴포넌트 기반 UI 구축
    상태 관리Zustand / React Query전역 상태 및 서버 상태 관리
    UI 라이브러리Material-UI (MUI)디자인 시스템 구현
    차트Recharts통계 데이터 시각화
    -
    -
    -

    모바일 애플리케이션

    - - - - - - - -
    카테고리기술목적
    프레임워크React Native크로스 플랫폼 앱 개발
    위치 서비스React Native Geolocation방문 추심 GPS 기록
    푸시 알림Firebase Cloud Messaging실시간 알림 발송
    -
    -
    -
    - -
    -
    -

    개발 로드맵

    - - - - - - - - - - -
    단계기간주요 마일스톤산출물
    Phase 1: 기획4주요구사항 정의, 시스템 설계PRD, 아키텍처 문서
    Phase 2: 디자인4주UI/UX 설계, 디자인 시스템Figma 프로토타입
    Phase 3: MVP 개발12주채권 관리, 추심 활동 핵심 기능Alpha 버전
    Phase 4: 통합 테스트4주법적 절차 연동 및 테스트Beta 버전
    Phase 5: 파일럿4주실사용자 테스트개선사항 리스트
    Phase 6: 정식 출시2주버그 수정 및 최적화v1.0 릴리스
    -
    -

    예상 리소스

    -
      -
    • 기획: PM 1명, 채권추심 전문가 1명
    • -
    • 디자인: UI/UX 디자이너 2명
    • -
    • 백엔드: 시니어 3명, 주니어 2명
    • -
    • 프론트엔드: 웹 2명, 모바일 2명
    • -
    • QA: 테스터 2명
    • -
    -
    -
    -
    - -
    -
    -

    성공 지표

    -
    -

    정량적 KPI

    - - - - - - - - - -
    지표목표치측정 방법
    업무 시간 절감60% 이상추심 활동 소요 시간 비교
    회수율 향상15% 증가시스템 도입 전후 회수율 비교
    사용자 만족도NPS 60 이상분기별 만족도 조사
    시스템 가용성99.9% 이상Uptime 모니터링
    모바일 사용률50% 이상모바일 앱 활성 사용자
    -
    -
    -
    - -
    -
    -

    맺음말

    -
    -

    채권관리의 새로운 패러다임

    -

    자동화로 시간을 절약하고, 체계적 관리로 회수율을 높이며, 법규 준수로 리스크를 최소화하는 채권추심 솔루션

    -
    -
    -

    핵심 차별점

    -
    -

    완전 자동화

    채권 등록부터 회수까지 End-to-End 자동화

    -

    모바일 우선

    현장에서 즉시 추심 활동 기록

    -

    법규 준수

    공정채권추심법 자동 준수

    -

    강력한 보안

    개인정보 암호화 및 접근 제어

    -
    -
    -
    -

    예상 효과

    -

    업무 시간 60% 절감 | 회수율 15% 향상 | 법규 준수 자동 보장

    -
    -
    -
    - -
    -
    -

    감사합니다

    -
    -

    장기적 채권추심 전략 - SAM 프로젝트

    -
    -

    문의 및 피드백

    -

    본 계획안에 대한 의견이나 추가 논의가 필요하신 경우

    -

    프로젝트 팀으로 연락 주시기 바랍니다.

    -
    -
    -
    -
    -
    - -
    - 1 / 14 -
    - - -@endsection - -@push('scripts') - -@endpush \ No newline at end of file diff --git a/resources/views/lab/strategy/mrp-overseas.blade.php b/resources/views/lab/strategy/mrp-overseas.blade.php deleted file mode 100644 index c0da188b..00000000 --- a/resources/views/lab/strategy/mrp-overseas.blade.php +++ /dev/null @@ -1,117 +0,0 @@ -@extends('layouts.presentation') - -@section('title', '해외 MRP 시스템 분석') - -@push('styles') - -@endpush - -@section('content') -
    -
    - -
    - - - -

    해외 MRP 시스템 분석

    -

    - 글로벌 MRP(Material Requirements Planning) 솔루션 비교와 - 해외 제조 기업의 도입 사례를 분석합니다. -

    -
    Coming Soon
    -
    - -
    -
    -

    - - - - 예정 콘텐츠 -

    -
    -
    -

    글로벌 MRP 솔루션

    -
      -
    • • SAP S/4HANA
    • -
    • • Oracle Cloud SCM
    • -
    • • Microsoft Dynamics 365
    • -
    • • Infor CloudSuite
    • -
    -
    -
    -

    기능 비교

    -
      -
    • • 수요 예측 알고리즘
    • -
    • • 재고 최적화 기능
    • -
    • • 공급망 가시성
    • -
    • • AI/ML 통합 수준
    • -
    -
    -
    -

    도입 사례

    -
      -
    • • 자동차 산업 적용 사례
    • -
    • • 전자 제조업 도입 효과
    • -
    • • 중소기업 맞춤 솔루션
    • -
    -
    -
    -

    비용 분석

    -
      -
    • • 라이선스 모델 비교
    • -
    • • 구현 비용 산정
    • -
    • • TCO(총소유비용) 분석
    • -
    • • ROI 기대 효과
    • -
    -
    -
    -
    -
    - -
    -
    -@endsection diff --git a/resources/views/lab/strategy/tax.blade.php b/resources/views/lab/strategy/tax.blade.php deleted file mode 100644 index 7a5f8932..00000000 --- a/resources/views/lab/strategy/tax.blade.php +++ /dev/null @@ -1,872 +0,0 @@ -@extends('layouts.app') - -@section('title', '장기적 세무전략') - -@push('styles') - -@endpush - -@section('content') -
    - -
    -
    -

    장기적 세무전략

    -

    통합 세무 관리 시스템 중장기 계획안

    -
    -

    기획 · 디자인 · 백엔드 · 프론트엔드

    -

    2025년 세무 자동화 솔루션 - 중장기 발전 계획

    -
    -
    -
    - - -
    -
    -

    목차

    -
    -

    1-4. 프로젝트 개요

    비전, 목표, 핵심 가치, 기대 효과

    -

    5-8. 기획 관점

    요구사항 정의, 사용자 시나리오, 기능 명세

    -

    9-11. 디자인 관점

    UI/UX 전략, 디자인 시스템, 사용자 경험

    -

    12-14. 백엔드 관점

    시스템 아키텍처, 데이터베이스 설계, API 설계

    -

    15-16. 프론트엔드 관점

    기술 스택, 컴포넌트 구조, 개발 로드맵

    -
    -
    -
    - - -
    -
    -

    프로젝트 비전

    -
    -

    세무 업무의 디지털 혁신

    -

    복잡한 세무 업무를 자동화하고, 전략적 의사결정을 지원하는 통합 플랫폼

    -
    -
    -

    핵심 목표

    -
    -

    업무 자동화

    반복적인 세무 업무를 90% 이상 자동화

    -

    데이터 통합

    회계, 인사, 영업 데이터의 실시간 통합

    -

    리스크 관리

    세무 리스크 사전 감지 및 알림

    -

    전략 수립

    데이터 기반 세무 전략 의사결정 지원

    -
    -
    -
    -

    주요 타겟 사용자

    -
      -
    • 세무사/회계사: 전문가용 세무 관리 도구
    • -
    • 기업 재무팀: 내부 세무 업무 자동화
    • -
    • 중소기업: 간편한 세무 컴플라이언스 관리
    • -
    • 스타트업: 초기 세무 구조 설계 지원
    • -
    -
    -
    -
    - - -
    -
    -

    핵심 가치 제안

    -
    -

    자동화

    세금 계산, 신고서 작성, 납부 관리 자동화

    매월 80시간 업무 시간 절감

    -

    통합성

    회계 시스템, ERP, 급여 시스템 연동

    데이터 이중 입력 제거

    -

    접근성

    클라우드 기반 언제 어디서나 접근

    PC, 태블릿, 모바일 지원

    -

    정확성

    최신 세법 자동 반영 및 검증

    인적 오류 95% 감소

    -

    인사이트

    AI 기반 세무 최적화 제안

    절세 기회 자동 발견

    -

    보안

    금융권 수준의 데이터 보안

    암호화 및 접근 제어

    -
    -
    -
    - - -
    -
    -

    기획: 기능 요구사항

    -
    -

    1. 세무 신고 관리

    -
      -
    • 부가가치세 신고서 자동 작성 및 전송
    • -
    • 법인세/소득세 예정/확정 신고 관리
    • -
    • 원천세 신고 및 납부 스케줄링
    • -
    • 지방세 통합 관리 (취득세, 재산세 등)
    • -
    -
    -
    -

    2. 세무 회계 처리

    -
      -
    • 매입매출 전표 자동 분개 처리
    • -
    • 계정과목 자동 매핑 및 학습
    • -
    • 세무조정 항목 관리
    • -
    • 손익계산서 및 재무상태표 생성
    • -
    -
    -
    -

    3. 증빙 관리

    -
      -
    • 전자세금계산서 발행 및 수신
    • -
    • 현금영수증 및 카드매출 통합
    • -
    • 증빙 스캔 및 OCR 자동 인식
    • -
    • 증빙 보관 및 검색 시스템
    • -
    -
    -
    -
    - - -
    -
    -

    기획: 사용자 시나리오

    -
    -

    시나리오 1: 부가세 신고 프로세스

    -
      -
    1. 데이터 수집: ERP 및 회계 시스템에서 매입/매출 데이터 자동 수집
    2. -
    3. 검증: AI 기반 이상 거래 탐지 및 알림
    4. -
    5. 신고서 작성: 부가세 신고서 자동 생성 및 미리보기
    6. -
    7. 승인: 담당자 검토 및 전자서명
    8. -
    9. 전송: 국세청 홈택스 자동 전송
    10. -
    11. 납부: 계좌 연동 자동 납부 또는 납부서 출력
    12. -
    -
    -
    -

    시나리오 2: 절세 전략 수립

    -
      -
    1. 현황 분석: 현재 세무 부담 및 비용 구조 분석
    2. -
    3. 시뮬레이션: 다양한 절세 시나리오 자동 시뮬레이션
    4. -
    5. 추천: AI 기반 최적 절세 방안 제시
    6. -
    7. 실행: 선택한 전략의 단계별 실행 가이드
    8. -
    9. 모니터링: 효과 추적 및 리포팅
    10. -
    -
    -
    -
    - - -
    -
    -

    기획: 고급 기능

    -
    -

    AI 세무 어시스턴트

    자연어 질의응답으로 세무 문의 즉시 해결

    "이번 달 부가세 예상액은?" 같은 질문에 즉시 답변

    -

    세무 대시보드

    실시간 세무 현황 및 KPI 모니터링

    납부 일정, 세부담률, 공제 현황 한눈에 확인

    -

    리스크 알림

    세무 리스크 사전 감지 및 예방

    신고 누락, 기한 임박, 이상 거래 자동 알림

    -

    절세 시뮬레이터

    다양한 시나리오별 세금 영향 분석

    투자, 채용, 구조조정 시 세무 영향 사전 검토

    -

    외부 시스템 연동

    ERP, 급여, 회계 시스템 자동 연동

    더존, 영림원, SAP, 더블린 등 주요 시스템 지원

    -

    모바일 승인

    언제 어디서나 신고서 검토 및 승인

    푸시 알림 및 생체인증 기반 전자서명

    -
    -
    -

    컴플라이언스 기능

    -
      -
    • 세법 자동 업데이트: 개정 세법 자동 반영 및 영향 분석
    • -
    • 전자신고 통합: 홈택스, 위택스 등 전자신고 시스템 연동
    • -
    • 감사 추적: 모든 세무 처리 이력 완벽 기록 및 추적
    • -
    • 권한 관리: 역할 기반 접근 제어 및 승인 워크플로우
    • -
    -
    -
    -
    - - -
    -
    -

    기획: 사용자 권한 체계

    - - - - - - - - - - -
    역할주요 권한접근 범위
    시스템 관리자전체 시스템 설정, 사용자 관리, 감사 로그 조회전체
    세무 책임자신고서 최종 승인, 전략 수립, 리포트 생성전체 세무 데이터
    세무 담당자신고서 작성, 증빙 처리, 세무조정담당 업무 데이터
    회계 담당자전표 입력, 계정 관리, 증빙 등록회계 데이터
    경영진대시보드 조회, 리포트 확인 (읽기 전용)요약 데이터
    외부 세무사자문, 검토, 신고 대행 (제한적 접근)위임받은 데이터
    -
    -

    워크플로우 승인 체계

    -
    -

    1단계: 세무 담당자가 신고서 작성

    -

    2단계: 회계 담당자가 데이터 검증

    -

    3단계: 세무 책임자가 최종 승인

    -

    4단계: 시스템 자동 전송 또는 경영진 통보

    -
    -
    -
    -
    - - -
    -
    -

    디자인: UI/UX 전략

    -
    -

    디자인 철학

    -

    "복잡한 세무를 단순하게, 전문성을 직관적으로"

    -
    -
    -

    핵심 디자인 원칙

    -
    -

    단순성

    복잡한 세무 프로세스를 단계별로 분해

    마법사(Wizard) 형태의 진행 방식

    -

    시각화

    숫자와 데이터를 직관적인 차트로 표현

    인포그래픽 중심의 대시보드

    -

    효율성

    최소 클릭으로 작업 완료

    자주 쓰는 기능 빠른 실행

    -

    일관성

    통일된 디자인 언어 및 패턴

    학습 곡선 최소화

    -

    접근성

    WCAG 2.1 AA 레벨 준수

    키보드 내비게이션 완벽 지원

    -

    반응형

    모든 디바이스에서 최적 경험

    PC, 태블릿, 모바일 대응

    -
    -
    -
    -

    컬러 시스템

    -
    -

    Primary

    #2563eb

    -

    Success

    #10b981

    -

    Warning

    #f59e0b

    -

    Danger

    #ef4444

    -
    -
    -
    -
    - - -
    -
    -

    디자인: 디자인 시스템

    -
    -

    컴포넌트 라이브러리

    - - - - - - - - - - -
    컴포넌트용도변형
    Button주요 액션 실행Primary, Secondary, Outline, Text
    Card정보 그룹핑Basic, Elevated, Outlined
    Table데이터 표시Sortable, Filterable, Paginated
    Form데이터 입력Input, Select, Datepicker, Autocomplete
    Modal팝업 대화상자Alert, Confirm, Form
    Chart데이터 시각화Line, Bar, Pie, Donut
    -
    -
    -

    타이포그래피

    -
    -

    본문: Noto Sans KR (가독성 최적화)

    -

    숫자: Roboto Mono (표 및 금액 표시)

    -

    제목: Spoqa Han Sans (명확한 위계)

    -
    -
    -
    -

    아이콘 시스템

    -
      -
    • Material Icons (기본 UI 아이콘)
    • -
    • Custom Tax Icons (세무 특화 아이콘)
    • -
    • 일관된 24x24px 그리드 시스템
    • -
    • Outlined, Filled 스타일 제공
    • -
    -
    -
    -
    - - -
    -
    -

    디자인: 사용자 경험 설계

    -
    -

    대시보드 레이아웃

    -
      -
    • 상단: 주요 KPI 카드 (이번 달 납부 세액, 공제액, 세부담률)
    • -
    • 중단: 일정 타임라인 (다가오는 신고 및 납부 일정)
    • -
    • 하단: 상세 차트 (월별 세금 추이, 업종 비교)
    • -
    • 사이드: 빠른 실행 메뉴 및 알림 센터
    • -
    -
    -
    -

    신고서 작성 UX

    -
      -
    1. Step 1 - 기간 선택: 신고 대상 기간 및 신고 유형 선택
    2. -
    3. Step 2 - 데이터 확인: 자동 수집된 데이터 검토 및 수정
    4. -
    5. Step 3 - 계산 검증: 세액 계산 결과 미리보기
    6. -
    7. Step 4 - 첨부 서류: 필요 서류 업로드 및 전자서명
    8. -
    9. Step 5 - 최종 확인: 요약 정보 확인 및 제출
    10. -
    -

    진행 표시: 각 단계별 진행 상황 프로그레스 바 표시

    -
    -
    -

    피드백 및 알림 시스템

    -
    -

    성공 메시지

    작업 완료 시 명확한 확인 메시지

    -

    경고 알림

    주의가 필요한 항목 강조 표시

    -

    오류 안내

    문제 발생 시 해결 방법 제시

    -

    도움말

    컨텍스트 기반 도움말 제공

    -
    -
    -
    -
    - - -
    -
    -

    백엔드: 시스템 아키텍처

    -
    -

    마이크로서비스 아키텍처

    -

    확장 가능하고 유지보수가 쉬운 분산 시스템

    -
    -
    -

    주요 서비스 구성

    -
    -

    인증 서비스

    JWT 기반 인증 및 권한 관리

    OAuth 2.0, 2FA 지원

    -

    데이터 수집 서비스

    외부 시스템 데이터 수집 및 변환

    ETL 파이프라인

    -

    계산 엔진

    세금 계산 및 시뮬레이션

    규칙 엔진 기반

    -

    신고서 생성

    각종 신고서 자동 작성

    PDF/XML 변환

    -

    통합 서비스

    ERP 및 회계 시스템 연동

    API Gateway

    -

    AI 서비스

    자연어 처리 및 예측 분석

    TensorFlow/PyTorch

    -
    -
    -
    -

    기술 스택

    - - - - - - - - - - -
    레이어기술목적
    API GatewayKong / AWS API Gateway라우팅, 인증, 속도 제한
    ApplicationNode.js (NestJS) / Python (FastAPI)비즈니스 로직 처리
    DatabasePostgreSQL / MongoDB관계형/문서형 데이터 저장
    CacheRedis세션, 계산 결과 캐싱
    Message QueueRabbitMQ / Apache Kafka비동기 처리, 이벤트 스트리밍
    SearchElasticsearch전체 텍스트 검색, 로그 분석
    -
    -
    -
    - - -
    -
    -

    백엔드: 데이터베이스 설계

    -
    -

    핵심 테이블 구조

    - - - - - - - - - - -
    테이블명주요 필드관계
    companiesid, name, business_number, tax_type1:N users, tax_returns
    usersid, email, role, company_idN:1 companies
    tax_returnsid, type, period, status, company_idN:1 companies, 1:N items
    tax_itemsid, return_id, account, amountN:1 tax_returns
    receiptsid, type, date, amount, file_pathN:1 companies
    tax_rulesid, name, formula, effective_date독립 테이블
    -
    -
    -

    데이터 보안 전략

    -
      -
    • 암호화: 민감 데이터 AES-256 암호화 (금액, 계좌번호 등)
    • -
    • 접근 제어: Row-Level Security (RLS) 적용
    • -
    • 감사 로그: 모든 데이터 변경 이력 자동 기록
    • -
    • 백업: 일일 자동 백업 및 Point-in-Time Recovery 지원
    • -
    • 다중화: Master-Slave Replication으로 고가용성 확보
    • -
    -
    -
    -

    성능 최적화

    -
      -
    • 주요 쿼리 대상 컬럼에 인덱스 생성
    • -
    • 자주 조회되는 집계 데이터 Materialized View 활용
    • -
    • 파티셔닝: 연도별 세금 신고 데이터 분할
    • -
    • 커넥션 풀링으로 DB 연결 관리 최적화
    • -
    -
    -
    -
    - - -
    -
    -

    백엔드: API 설계

    -
    -

    RESTful API 엔드포인트

    - - - - - - - - - - - - -
    엔드포인트메서드설명
    /api/v1/auth/loginPOST사용자 로그인 및 JWT 발급
    /api/v1/tax-returnsGET세금 신고서 목록 조회
    /api/v1/tax-returnsPOST새 신고서 생성
    /api/v1/tax-returns/:idPUT신고서 수정
    /api/v1/tax-returns/:id/submitPOST신고서 제출
    /api/v1/receipts/uploadPOST증빙 파일 업로드 및 OCR 처리
    /api/v1/dashboard/summaryGET대시보드 요약 정보
    /api/v1/ai/askPOSTAI 세무 어시스턴트 질의
    -
    -
    -

    API 보안 및 품질

    -
    -

    인증/인가

    JWT Bearer Token 기반 인증

    역할 기반 접근 제어 (RBAC)

    -

    문서화

    OpenAPI 3.0 (Swagger) 자동 생성

    인터랙티브 API 문서

    -

    속도 제한

    Rate Limiting (100 req/min/user)

    DDoS 방어

    -

    입력 검증

    DTO 기반 자동 검증

    타입 안전성 보장

    -

    모니터링

    API 성능 및 오류율 추적

    Prometheus + Grafana

    -

    버전 관리

    URL 기반 버저닝 (/v1/, /v2/)

    하위 호환성 보장

    -
    -
    -
    -
    - - -
    -
    -

    프론트엔드: 기술 스택

    -
    -

    현대적인 프론트엔드 아키텍처

    -

    React 18 + TypeScript 기반 SPA

    -
    -
    -

    핵심 기술 스택

    - - - - - - - - - - - - - - -
    카테고리기술목적
    프레임워크React 18컴포넌트 기반 UI 구축
    언어TypeScript타입 안전성, 개발 생산성
    상태 관리Zustand / React Query전역 상태 및 서버 상태 관리
    라우팅React Router v6SPA 페이지 라우팅
    UI 라이브러리Material-UI (MUI)디자인 시스템 구현
    차트Recharts / Chart.js데이터 시각화
    폼 관리React Hook Form폼 검증 및 상태 관리
    스타일링Emotion / Styled ComponentsCSS-in-JS
    빌드 도구Vite빠른 개발 서버 및 빌드
    테스트Vitest / Testing Library유닛/통합 테스트
    -
    -
    -

    개발 도구

    -
      -
    • 코드 품질: ESLint, Prettier, Husky (pre-commit hooks)
    • -
    • 번들 분석: Vite Bundle Analyzer로 최적화
    • -
    • 성능 모니터링: React DevTools, Lighthouse CI
    • -
    • 문서화: Storybook으로 컴포넌트 문서화
    • -
    -
    -
    -
    - - -
    -
    -

    프론트엔드: 컴포넌트 구조

    -
    -

    폴더 구조

    -
    -
    src/
    -├── components/          # 재사용 가능 컴포넌트
    -│   ├── common/         # 공통 UI 컴포넌트 (Button, Input 등)
    -│   ├── layout/         # 레이아웃 컴포넌트 (Header, Sidebar)
    -│   └── domain/         # 도메인 특화 컴포넌트
    -├── pages/              # 페이지 컴포넌트
    -│   ├── Dashboard/
    -│   ├── TaxReturn/
    -│   └── Settings/
    -├── hooks/              # 커스텀 React Hooks
    -├── services/           # API 호출 로직
    -├── stores/             # 상태 관리 (Zustand)
    -├── types/              # TypeScript 타입 정의
    -├── utils/              # 유틸리티 함수
    -└── assets/             # 정적 파일 (이미지, 폰트)
    -
    -
    -
    -

    주요 컴포넌트

    -
    -

    DashboardWidget

    대시보드 KPI 카드 및 차트

    -

    TaxReturnWizard

    단계별 신고서 작성 마법사

    -

    ReceiptUploader

    증빙 파일 업로드 및 OCR

    -

    TaxChart

    세무 데이터 시각화

    -

    NotificationCenter

    알림 및 작업 센터

    -

    AIAssistant

    채팅 기반 세무 도우미

    -
    -
    -
    -

    성능 최적화 전략

    -
      -
    • Code Splitting: 페이지 단위 lazy loading으로 초기 로딩 속도 개선
    • -
    • Memoization: React.memo, useMemo, useCallback 활용
    • -
    • Virtual Scrolling: 대용량 테이블 렌더링 최적화
    • -
    • 이미지 최적화: WebP 포맷, lazy loading, responsive images
    • -
    • 캐싱: React Query로 서버 데이터 캐싱 및 재검증
    • -
    -
    -
    -
    - - -
    -
    -

    개발 로드맵

    - - - - - - - - - - -
    단계기간주요 마일스톤산출물
    Phase 1: 기획4주요구사항 정의, 시스템 설계PRD, 아키텍처 문서
    Phase 2: 디자인4주UI/UX 설계, 디자인 시스템 구축Figma 프로토타입, 디자인 가이드
    Phase 3: MVP 개발12주핵심 기능 구현 (신고, 증빙, 대시보드)Alpha 버전
    Phase 4: 통합 테스트4주외부 시스템 연동 및 테스트Beta 버전
    Phase 5: 파일럿4주실사용자 테스트 및 피드백 수집개선사항 리스트
    Phase 6: 정식 출시2주버그 수정 및 성능 최적화v1.0 릴리스
    -
    -

    주요 기술 과제

    -
      -
    • 세법 규칙 엔진: 복잡한 세법 규칙의 유연한 모델링
    • -
    • 대용량 데이터 처리: 수백만 건의 거래 데이터 실시간 처리
    • -
    • 외부 시스템 통합: 다양한 ERP/회계 시스템 API 연동
    • -
    • 보안 및 컴플라이언스: 금융권 수준의 보안 요구사항 충족
    • -
    • AI 모델 학습: 세무 데이터 기반 예측 모델 개발
    • -
    -
    -
    -

    예상 리소스

    -
      -
    • 기획: PM 1명, 세무 도메인 전문가 1명
    • -
    • 디자인: UI/UX 디자이너 2명
    • -
    • 백엔드: 시니어 개발자 3명, 주니어 개발자 2명
    • -
    • 프론트엔드: 시니어 개발자 2명, 주니어 개발자 2명
    • -
    • QA: 테스터 2명
    • -
    • DevOps: 인프라 엔지니어 1명
    • -
    -
    -
    -
    - - -
    -
    -

    성공 지표

    -
    -

    📊 정량적 KPI

    - - - - - - - - - - -
    지표목표치측정 방법
    업무 시간 절감80% 이상신고 준비 소요 시간 Before/After 비교
    오류율 감소95% 이상수정 신고 건수 감소율
    사용자 만족도NPS 50 이상분기별 사용자 설문 조사
    시스템 가용성99.9% 이상Uptime 모니터링
    API 응답 속도평균 200ms 이하APM 도구 (New Relic/Datadog)
    월간 활성 사용자80% 이상Google Analytics / Mixpanel
    -
    -
    -

    🎯 정성적 목표

    -
    -

    혁신성

    국내 최초 AI 기반 세무 자동화 플랫폼 구축

    -

    시장 포지션

    중소기업 세무 솔루션 시장 점유율 Top 3

    -

    파트너십

    주요 ERP/회계 시스템과 공식 파트너십 체결

    -

    보안 인증

    ISO 27001, SOC 2 Type 2 인증 획득

    -
    -
    -
    -
    - - -
    -
    -

    리스크 관리

    - - - - - - - - - - -
    리스크영향대응 전략
    세법 변경높음규칙 엔진 기반 설계로 신속 대응, 법률 자문 확보
    외부 시스템 장애중간Fallback 메커니즘, 수동 입력 옵션 제공
    데이터 보안 침해매우 높음다층 보안 체계, 정기 보안 감사, 침해 대응 계획 수립
    개발 일정 지연중간Agile 방법론, 단계별 마일스톤 관리
    사용자 채택률 저조높음직관적 UX, 충분한 교육 및 온보딩, 전담 지원팀
    AI 모델 정확도중간Human-in-the-loop 검증, 점진적 자동화 확대
    -
    -

    규제 준수 전략

    -
      -
    • 전자세금계산서법: 국세청 표준 XML 스키마 준수
    • -
    • 개인정보보호법: PIMS 인증 획득, 개인정보 암호화 저장
    • -
    • 전자금융거래법: 전자서명 및 공인인증서 연동
    • -
    • 회계 기준: K-IFRS 및 일반기업회계기준 준수
    • -
    -
    -
    -
    - - -
    -
    -

    맺음말

    -
    -

    세무 업무의 새로운 패러다임

    -

    자동화로 시간을 절약하고, AI로 인사이트를 발견하며, 통합으로 효율을 극대화하는 차세대 세무 솔루션

    -
    -
    -

    핵심 차별점

    -
    -

    완전 자동화

    데이터 수집부터 신고까지 End-to-End 자동화

    -

    AI 기반

    머신러닝으로 패턴 학습 및 절세 전략 제안

    -

    완벽한 통합

    모든 주요 ERP/회계 시스템 네이티브 연동

    -

    언제 어디서나

    클라우드 기반으로 시간과 장소의 제약 없음

    -

    기업급 보안

    금융권 수준의 다층 보안 체계

    -

    직관적 UX

    복잡한 세무를 누구나 쉽게 처리

    -
    -
    -
    -

    예상 효과

    -

    업무 시간 80% 절감 | 오류율 95% 감소 | 절세 기회 자동 발견

    -
    -
    -

    Next Steps

    -
      -
    1. 이해관계자 승인 및 예산 확보
    2. -
    3. 개발팀 구성 및 킥오프 미팅
    4. -
    5. Phase 1 상세 요구사항 정의 착수
    6. -
    7. 디자인 시스템 프로토타입 개발
    8. -
    -
    -
    -
    - - -
    -
    -

    감사합니다

    -
    -

    장기적 세무전략 - 중장기 계획안

    -
    -

    문의 및 피드백

    -

    본 계획안에 대한 의견이나 추가 논의가 필요하신 경우

    -

    프로젝트 팀으로 연락 주시기 바랍니다.

    -
    -
    -
    -
    -
    - -
    - 1 / 21 -
    - - -@endsection - -@push('scripts') - -@endpush \ No newline at end of file diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php index 60323cb9..4a7fa2a5 100644 --- a/resources/views/layouts/app.blade.php +++ b/resources/views/layouts/app.blade.php @@ -120,6 +120,8 @@ })(); + + diff --git a/resources/views/sales/dashboard/index.blade.php b/resources/views/sales/dashboard/index.blade.php index b6b7cb4f..266f22d1 100644 --- a/resources/views/sales/dashboard/index.blade.php +++ b/resources/views/sales/dashboard/index.blade.php @@ -43,4 +43,30 @@ @include('sales.dashboard.partials.data-container')
    + +{{-- 시나리오 모달용 포털 --}} + @endsection + +@push('scripts') + +@endpush diff --git a/resources/views/sales/dashboard/partials/data-container.blade.php b/resources/views/sales/dashboard/partials/data-container.blade.php index a0f767d9..19e96714 100644 --- a/resources/views/sales/dashboard/partials/data-container.blade.php +++ b/resources/views/sales/dashboard/partials/data-container.blade.php @@ -1,5 +1,10 @@ {{-- 대시보드 데이터 컨테이너 (HTMX로 새로고침되는 영역) --}} +{{-- 영업파트너 수당 현황 (파트너인 경우에만 표시) --}} +@if (isset($partner) && $partner) + @include('sales.dashboard.partials.my-commission') +@endif + {{-- 전체 누적 실적 --}} @include('sales.dashboard.partials.stats') @@ -72,14 +77,9 @@ class="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-
    -{{-- 역할별 수당 상세 --}} -@include('sales.dashboard.partials.commission-by-role') -{{-- 실적 데이터 없음 안내 --}} -@include('sales.dashboard.partials.no-data') - -{{-- 수익 및 테넌트 관리 --}} -@include('sales.dashboard.partials.tenant-stats') +{{-- 내 계약 현황 --}} +@include('sales.dashboard.partials.tenant-list') diff --git a/resources/views/sales/managers/create.blade.php b/resources/views/sales/managers/create.blade.php index 7babc470..4f4cc218 100644 --- a/resources/views/sales/managers/create.blade.php +++ b/resources/views/sales/managers/create.blade.php @@ -1,6 +1,88 @@ @extends('layouts.app') -@section('title', '영업담당자 등록') +@section('title', '영업파트너 등록') + +@push('styles') + +@endpush @section('content')
    @@ -12,8 +94,21 @@ 목록으로 -

    영업담당자 등록

    -

    등록 후 본사 승인이 필요합니다.

    +
    +
    +

    영업파트너 등록

    +

    등록 후 본사 승인이 필요합니다.

    +
    + @if(app()->environment('local', 'development')) + + @endif +
    @@ -107,13 +202,13 @@ class="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500">

    첨부 서류

    -

    승인에 필요한 서류를 첨부해주세요. (신분증, 사업자등록증, 계약서 등)

    +

    승인에 필요한 서류를 첨부해주세요. (등본사본, 통장사본)

    -
    -
    -
    +
    +
    +
    -
    +
    - +
    + + + + +

    클릭 또는 드래그하여 업로드

    +
    -
    +
    let documentIndex = 1; -document.getElementById('add-document-btn').addEventListener('click', function() { - const container = document.getElementById('document-list'); - const template = ` -
    - + `; + }; + reader.readAsDataURL(file); + } else { + const ext = file.name.split('.').pop().toUpperCase(); + preview.innerHTML = ` +
    ${ext}
    +
    +
    ${file.name}
    +
    ${fileSize}
    +
    + -
    -
    + `; + } + + dropZone.appendChild(preview); +} + +function removeFile(btn) { + const dropZone = btn.closest('.doc-drop-zone'); + const fileInput = dropZone.querySelector('input[type="file"]'); + const preview = dropZone.querySelector('.doc-preview'); + const icon = dropZone.querySelector('.doc-drop-zone-icon'); + const text = dropZone.querySelector('p'); + + // 파일 input 초기화 + fileInput.value = ''; + + // 미리보기 제거 + if (preview) preview.remove(); + + // 아이콘과 텍스트 다시 표시 + if (icon) icon.style.display = ''; + if (text) text.style.display = ''; + + dropZone.classList.remove('has-file'); +} + +function formatFileSize(bytes) { + if (bytes >= 1048576) return (bytes / 1048576).toFixed(1) + ' MB'; + if (bytes >= 1024) return (bytes / 1024).toFixed(1) + ' KB'; + return bytes + ' bytes'; +} + +// 문서 추가 버튼 +document.getElementById('add-document-btn').addEventListener('click', function() { + const container = document.getElementById('document-list'); + const template = ` +
    + +
    +
    -
    +
    - +
    + + + + +

    클릭 또는 드래그하여 업로드

    +
    -
    +
    { + cb.checked = true; + }); + + // 콘솔에 비밀번호 출력 (테스트용) + console.log('=== 테스트 계정 정보 ==='); + console.log('ID:', userId); + console.log('이메일:', email); + console.log('비밀번호:', password); + console.log('========================'); + + // 알림 + alert('테스트 데이터가 입력되었습니다.\n\n비밀번호: 12341234'); +} @endpush diff --git a/resources/views/sales/managers/edit.blade.php b/resources/views/sales/managers/edit.blade.php index 1aa9f5ef..d2dd54c0 100644 --- a/resources/views/sales/managers/edit.blade.php +++ b/resources/views/sales/managers/edit.blade.php @@ -1,6 +1,88 @@ @extends('layouts.app') -@section('title', '영업담당자 수정') +@section('title', '영업파트너 수정') + +@push('styles') + +@endpush @section('content')
    @@ -12,7 +94,7 @@ 목록으로 -

    영업담당자 수정

    +

    영업파트너 수정

    {{ $partner->name }} ({{ $partner->email }})

    @@ -143,9 +225,9 @@ class="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500">

    새 서류 추가

    -
    -
    -
    +
    +
    +
    -
    +
    - +
    + + + + +

    클릭 또는 드래그하여 업로드

    +
    -
    +
    let documentIndex = 1; -document.getElementById('add-document-btn').addEventListener('click', function() { - const container = document.getElementById('document-list'); - const template = ` -
    - + `; + }; + reader.readAsDataURL(file); + } else { + const ext = file.name.split('.').pop().toUpperCase(); + preview.innerHTML = ` +
    ${ext}
    +
    +
    ${file.name}
    +
    ${fileSize}
    +
    + -
    -
    + `; + } + + dropZone.appendChild(preview); +} + +function removeFile(btn) { + const dropZone = btn.closest('.doc-drop-zone'); + const fileInput = dropZone.querySelector('input[type="file"]'); + const preview = dropZone.querySelector('.doc-preview'); + const icon = dropZone.querySelector('.doc-drop-zone-icon'); + const text = dropZone.querySelector('p'); + + // 파일 input 초기화 + fileInput.value = ''; + + // 미리보기 제거 + if (preview) preview.remove(); + + // 아이콘과 텍스트 다시 표시 + if (icon) icon.style.display = ''; + if (text) text.style.display = ''; + + dropZone.classList.remove('has-file'); +} + +function formatFileSize(bytes) { + if (bytes >= 1048576) return (bytes / 1048576).toFixed(1) + ' MB'; + if (bytes >= 1024) return (bytes / 1024).toFixed(1) + ' KB'; + return bytes + ' bytes'; +} + +// 문서 추가 버튼 +document.getElementById('add-document-btn').addEventListener('click', function() { + const container = document.getElementById('document-list'); + const template = ` +
    + +
    +
    -
    +
    - +
    + + + + +

    클릭 또는 드래그하여 업로드

    +
    -
    +
    created_at->format('Y-m-d') }} - 상세 - 수정 + + @if($partner->isPendingApproval())
    @@ -187,4 +187,133 @@ class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none foc @endif
    + + + @endsection + +@push('scripts') + +@endpush diff --git a/resources/views/sales/managers/partials/edit-modal.blade.php b/resources/views/sales/managers/partials/edit-modal.blade.php new file mode 100644 index 00000000..93451266 --- /dev/null +++ b/resources/views/sales/managers/partials/edit-modal.blade.php @@ -0,0 +1,193 @@ +{{-- 영업파트너 수정 모달 내용 --}} +
    + +
    +
    +

    영업파트너 수정

    +

    {{ $partner->name }} ({{ $partner->email }})

    +
    + +
    + + + + @csrf + @method('PUT') + + +
    +

    기본 정보

    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    +

    역할 *

    +
    + @foreach($roles as $role) + + @endforeach +
    +
    + + + @if($partner->parent) +
    +

    추천인(유치자)

    +
    + {{ $partner->parent->name }} ({{ $partner->parent->email }}) +
    +

    추천인은 변경할 수 없습니다.

    +
    + @endif + + + @if($partner->salesDocuments->isNotEmpty()) +
    +

    기존 첨부 서류

    +
    + @foreach($partner->salesDocuments as $document) +
    +
    + {{ $document->document_type_label }} + {{ $document->original_name }} + {{ $document->formatted_size }} +
    + +
    + @endforeach +
    +
    + @endif + + +
    +

    새 서류 추가

    + + +
    + + +
    + + +
    + +
    + + diff --git a/resources/views/sales/managers/partials/show-modal.blade.php b/resources/views/sales/managers/partials/show-modal.blade.php new file mode 100644 index 00000000..5acedb67 --- /dev/null +++ b/resources/views/sales/managers/partials/show-modal.blade.php @@ -0,0 +1,290 @@ +{{-- 영업파트너 상세 모달 내용 --}} +
    + +
    +
    +

    {{ $partner->name }}

    +

    레벨 {{ $level }} 영업파트너

    +
    + @foreach($partner->userRoles as $userRole) + @php + $roleColor = match($userRole->role->name ?? '') { + 'sales' => 'bg-blue-100 text-blue-800', + 'manager' => 'bg-purple-100 text-purple-800', + 'recruiter' => 'bg-green-100 text-green-800', + default => 'bg-gray-100 text-gray-800', + }; + $roleLabel = match($userRole->role->name ?? '') { + 'sales' => '영업', + 'manager' => '매니저', + 'recruiter' => '유치담당', + default => $userRole->role->name ?? '-', + }; + @endphp + + {{ $roleLabel }} + + @endforeach + + {{ $partner->approval_status_label }} + +
    +
    + +
    + + + @if($partner->isPendingApproval()) +
    +
    + + + +
    +

    승인 대기 중

    +

    첨부된 서류를 확인 후 승인 또는 반려해주세요.

    +
    +
    + @csrf + +
    + +
    + + +
    +
    +
    + @endif + + + @if($partner->isRejected() && $partner->rejection_reason) +
    +
    + + + +
    +

    반려됨

    +

    {{ $partner->rejection_reason }}

    + @if($partner->approver) +

    처리자: {{ $partner->approver->name }} ({{ $partner->approved_at->format('Y-m-d H:i') }})

    + @endif +
    +
    +
    + @endif + +
    + +
    +

    기본 정보

    +
    +
    +
    로그인 ID
    +
    {{ $partner->user_id ?? $partner->email }}
    +
    +
    +
    이름
    +
    {{ $partner->name }}
    +
    +
    +
    이메일
    +
    {{ $partner->email }}
    +
    +
    +
    전화번호
    +
    {{ $partner->phone ?? '-' }}
    +
    +
    +
    추천인(유치자)
    +
    + @if($partner->parent) + {{ $partner->parent->name }} + @else + 최상위 + @endif +
    +
    +
    +
    등록일
    +
    {{ $partner->created_at->format('Y-m-d H:i') }}
    +
    + @if($partner->isApproved() && $partner->approved_at) +
    +
    승인일
    +
    {{ $partner->approved_at->format('Y-m-d H:i') }}
    +
    + @endif +
    +
    + + +
    +

    활동 통계

    +
    +
    +
    {{ $children->count() }}
    +
    하위 파트너
    +
    +
    +
    {{ $partner->salesDocuments->count() }}
    +
    첨부 서류
    +
    +
    +
    +
    + + + @if($partner->isApproved()) +
    +

    역할 관리

    + @php + $currentRoles = $partner->userRoles->pluck('role.name')->toArray(); + $roleLabels = ['sales' => '영업', 'manager' => '매니저', 'recruiter' => '유치담당']; + $roleColors = [ + 'sales' => 'bg-blue-100 text-blue-800 border-blue-200', + 'manager' => 'bg-purple-100 text-purple-800 border-purple-200', + 'recruiter' => 'bg-green-100 text-green-800 border-green-200', + ]; + @endphp +
    + @forelse($currentRoles as $roleName) + @if(isset($roleLabels[$roleName])) +
    + {{ $roleLabels[$roleName] }} +
    + @csrf + + +
    +
    + @endif + @empty + 역할이 없습니다 + @endforelse +
    +
    + @foreach(['sales' => '영업', 'manager' => '매니저', 'recruiter' => '유치담당'] as $roleName => $label) + @if(!in_array($roleName, $currentRoles)) +
    + @csrf + + +
    + @endif + @endforeach +
    +
    + @endif + + + @if($partner->salesDocuments->isNotEmpty()) +
    +

    첨부 서류

    +
    + @foreach($partner->salesDocuments as $document) +
    +
    + {{ $document->document_type_label }} + {{ $document->original_name }} +
    + 다운로드 +
    + @endforeach +
    +
    + @endif + + + @if($children->isNotEmpty()) +
    +

    하위 파트너 ({{ $children->count() }}명)

    +
    + @foreach($children->take(5) as $child) +
    +
    + {{ $child->name }} + @foreach($child->userRoles as $userRole) + @php + $roleColor = match($userRole->role->name ?? '') { + 'sales' => 'bg-blue-100 text-blue-800', + 'manager' => 'bg-purple-100 text-purple-800', + 'recruiter' => 'bg-green-100 text-green-800', + default => 'bg-gray-100 text-gray-800', + }; + $roleLabel = match($userRole->role->name ?? '') { + 'sales' => '영업', + 'manager' => '매니저', + 'recruiter' => '유치담당', + default => $userRole->role->name ?? '-', + }; + @endphp + {{ $roleLabel }} + @endforeach +
    + + {{ $child->approval_status_label }} + +
    + @endforeach + @if($children->count() > 5) +

    외 {{ $children->count() - 5 }}명...

    + @endif +
    +
    + @endif + + +
    + + +
    +
    + + diff --git a/resources/views/sales/modals/consultation-log.blade.php b/resources/views/sales/modals/consultation-log.blade.php new file mode 100644 index 00000000..41241d7c --- /dev/null +++ b/resources/views/sales/modals/consultation-log.blade.php @@ -0,0 +1,313 @@ +{{-- 상담 기록 컴포넌트 --}} +
    + {{-- 상담 기록 입력 --}} +
    +

    상담 기록 추가

    +
    + +
    + +
    +
    +
    + + {{-- 상담 기록 목록 --}} +
    +

    상담 기록 ({{ $consultations->count() }}건)

    + + @if($consultations->isEmpty()) +
    + + + +

    아직 상담 기록이 없습니다.

    +
    + @else +
    + @foreach($consultations as $consultation) +
    + {{-- 삭제 버튼 --}} + + + {{-- 콘텐츠 --}} +
    + {{-- 타입 아이콘 --}} +
    + @if($consultation->consultation_type === 'text') + + + + @elseif($consultation->consultation_type === 'audio') + + + + @else + + + + @endif +
    + +
    + @if($consultation->consultation_type === 'text') +
    +

    {{ $consultation->content }}

    +
    + @elseif($consultation->consultation_type === 'audio') +
    +
    + 음성 녹음 + @if($consultation->duration) + + {{ $consultation->formatted_duration }} + + @endif + @if($consultation->gcs_uri) + + + + + GCS + + @endif +
    + + {{-- 오디오 플레이어 --}} +
    + + + + + + + 다운로드 + +
    + + @if($consultation->transcript) +
    +

    "{{ $consultation->transcript }}"

    +
    + @endif +
    + @else + {{-- 첨부파일 --}} +
    +
    + {{ $consultation->file_name }} + + {{ $consultation->formatted_file_size }} + +
    + + + + + 다운로드 + +
    + @endif + + {{-- 메타 정보 --}} +
    + {{ $consultation->creator?->name ?? '알 수 없음' }} + | + {{ $consultation->created_at->format('Y-m-d H:i') }} +
    +
    +
    +
    + @endforeach +
    + @endif +
    +
    + + diff --git a/resources/views/sales/modals/file-uploader.blade.php b/resources/views/sales/modals/file-uploader.blade.php new file mode 100644 index 00000000..5ffff97d --- /dev/null +++ b/resources/views/sales/modals/file-uploader.blade.php @@ -0,0 +1,256 @@ +{{-- 첨부파일 업로드 컴포넌트 (자동 업로드) --}} +
    + + {{-- 업로드 중 오버레이 --}} +
    +
    + {{-- 아이콘 --}} +
    +
    + + + +
    +
    + {{-- 텍스트 --}} +

    파일 업로드 중...

    + {{-- 프로그레스바 --}} +
    +
    +
    +

    +
    +
    + + {{-- 헤더 (접기/펼치기) --}} + + + {{-- 콘텐츠 (접기/펼치기) --}} +
    + + {{-- Drag & Drop 영역 --}} +
    + + + + + + +

    + 파일을 여기에 드래그하거나 클릭하여 선택 +

    +

    + 선택 즉시 자동 업로드 / 최대 20MB +

    +
    + + {{-- 최근 업로드 파일 목록 --}} +
    +
    최근 업로드
    + +
    + + {{-- 안내 문구 --}} +

    + PDF, 문서, 이미지, 압축파일 지원 +

    +
    +
    + + diff --git a/resources/views/sales/modals/partials/product-selection.blade.php b/resources/views/sales/modals/partials/product-selection.blade.php new file mode 100644 index 00000000..46a80235 --- /dev/null +++ b/resources/views/sales/modals/partials/product-selection.blade.php @@ -0,0 +1,261 @@ +{{-- 계약 체결 시 상품 선택 컴포넌트 --}} +@php + use App\Models\Sales\SalesProductCategory; + use App\Models\Sales\SalesContractProduct; + + $categories = SalesProductCategory::active() + ->ordered() + ->with(['products' => fn($q) => $q->active()->ordered()]) + ->get(); + + // 이미 선택된 상품들 조회 + $selectedProducts = SalesContractProduct::where('tenant_id', $tenant->id) + ->pluck('product_id') + ->toArray(); + + // 기존 계약 상품 정보 (가격 커스터마이징 포함) + $contractProducts = SalesContractProduct::where('tenant_id', $tenant->id) + ->get() + ->keyBy('product_id'); +@endphp + +
    +
    +
    + + + +
    +
    +

    SAM 솔루션 상품 선택

    +

    고객사에 제공할 솔루션 패키지를 선택하세요

    +
    +
    + + {{-- 카테고리 탭 --}} +
    + @foreach($categories as $category) + + @endforeach +
    + + {{-- 상품 목록 --}} + @foreach($categories as $category) +
    +
    + @foreach($category->products as $product) + @php + $isSelected = in_array($product->id, $selectedProducts); + $contractProduct = $contractProducts->get($product->id); + $devFee = $contractProduct?->development_fee ?? $product->development_fee; + $subFee = $contractProduct?->subscription_fee ?? $product->subscription_fee; + @endphp +
    +
    +
    + {{-- 체크박스 --}} + + +
    +
    + {{ $product->name }} + @if($product->is_required) + 필수 + @endif + @if($product->allow_flexible_pricing) + 재량권 + @endif +
    + @if($product->description) +

    {{ $product->description }}

    + @endif +
    + 가입비: + {{ $product->formatted_development_fee }} + {{ $product->formatted_registration_fee }} + + 월 구독료: {{ $product->formatted_subscription_fee }} +
    +
    +
    +
    +
    + @endforeach +
    +
    + @endforeach + + {{-- 합계 영역 --}} +
    +
    +
    +
    +

    선택 상품

    +

    +
    +
    +

    총 가입비

    +

    +
    +
    +

    월 구독료

    +

    +
    +
    +
    +
    + + {{-- 저장 버튼 --}} +
    + +
    +
    + + diff --git a/resources/views/sales/modals/scenario-modal.blade.php b/resources/views/sales/modals/scenario-modal.blade.php new file mode 100644 index 00000000..78c6c30f --- /dev/null +++ b/resources/views/sales/modals/scenario-modal.blade.php @@ -0,0 +1,270 @@ +{{-- 영업/매니저 시나리오 모달 --}} +@php + $stepProgressJson = json_encode($progress['steps'] ?? []); +@endphp + +
    + + {{-- 배경 오버레이 --}} +
    + + {{-- 모달 컨테이너 --}} +
    +
    + + {{-- 모달 헤더 --}} +
    +
    +
    + @if($scenarioType === 'sales') + + + + @else + + + + @endif +
    +
    +

    + {{ $scenarioType === 'sales' ? '영업 전략 시나리오' : '매니저 상담 프로세스' }} +

    +

    {{ $tenant->company_name }}

    +
    +
    +
    + {{-- 전체 진행률 --}} +
    + 진행률 + +
    + {{-- 닫기 버튼 --}} + +
    +
    + + {{-- 모달 바디 --}} +
    + {{-- 좌측 사이드바: 단계 네비게이션 --}} +
    +
    +

    단계별 진행

    + +
    +
    + + {{-- 우측 메인 영역 --}} +
    + {{-- 단계별 콘텐츠 (스크롤 가능) --}} +
    +
    + @include('sales.modals.scenario-step', [ + 'step' => collect($steps)->firstWhere('id', $currentStep), + 'steps' => $steps, + 'tenant' => $tenant, + 'scenarioType' => $scenarioType, + 'progress' => $progress, + 'icons' => $icons, + ]) +
    +
    + + {{-- 하단 고정: 상담 기록 및 첨부파일 (모든 단계 공유) --}} +
    + {{-- 아코디언 헤더 --}} + + + {{-- 아코디언 콘텐츠 --}} +
    +
    + {{-- 상담 기록 --}} +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + + {{-- 음성 녹음 --}} +
    + @include('sales.modals.voice-recorder', [ + 'tenant' => $tenant, + 'scenarioType' => $scenarioType, + 'stepId' => null, + ]) +
    + + {{-- 첨부파일 업로드 --}} +
    + @include('sales.modals.file-uploader', [ + 'tenant' => $tenant, + 'scenarioType' => $scenarioType, + 'stepId' => null, + ]) +
    +
    +
    +
    +
    +
    +
    +
    +
    diff --git a/resources/views/sales/modals/scenario-step.blade.php b/resources/views/sales/modals/scenario-step.blade.php new file mode 100644 index 00000000..dee0df34 --- /dev/null +++ b/resources/views/sales/modals/scenario-step.blade.php @@ -0,0 +1,208 @@ +{{-- 시나리오 단계별 체크리스트 --}} +@php + use App\Models\Sales\SalesScenarioChecklist; + + // $steps가 없거나 비어있으면 config에서 가져오기 (안전장치) + if (empty($steps)) { + $steps = config($scenarioType === 'sales' ? 'sales_scenario.sales_steps' : 'sales_scenario.manager_steps', []); + } + $step = $step ?? collect($steps)->firstWhere('id', $currentStep ?? 1); + // DB에서 체크된 항목 조회 + $checklist = SalesScenarioChecklist::getChecklist($tenant->id, $scenarioType); +@endphp + +
    + {{-- 단계 헤더 --}} +
    +
    + + {!! $icons[$step['icon']] ?? '' !!} + +
    +
    +
    + STEP {{ $step['id'] }} + {{ $step['subtitle'] }} +
    +

    {{ $step['title'] }}

    +

    {{ $step['description'] }}

    +
    +
    + + {{-- 매니저용 팁 (있는 경우) --}} + @if(isset($step['tips'])) +
    +
    + + + +
    +

    매니저 TIP

    +

    {{ $step['tips'] }}

    +
    +
    +
    + @endif + + {{-- 체크포인트 목록 --}} +
    + @foreach($step['checkpoints'] as $checkpoint) + @php + $checkKey = "{$step['id']}_{$checkpoint['id']}"; + $isChecked = isset($checklist[$checkKey]); + @endphp +
    + + {{-- 체크포인트 헤더 --}} +
    + {{-- 체크박스 --}} + + + {{-- 제목 및 설명 --}} +
    +

    + {{ $checkpoint['title'] }} +

    +

    {{ $checkpoint['detail'] }}

    +
    + + {{-- 확장 아이콘 --}} + + + +
    + + {{-- 확장 콘텐츠 --}} +
    +
    + {{-- 상세 설명 --}} +
    +
    상세 설명
    +

    {{ $checkpoint['detail'] }}

    +
    + + {{-- PRO TIP --}} +
    +
    +
    + + + +
    +
    +

    PRO TIP

    +

    {{ $checkpoint['pro_tip'] }}

    +
    +
    +
    +
    +
    +
    + @endforeach +
    + + {{-- 계약 체결 단계 (Step 6)에서만 상품 선택 컴포넌트 표시 --}} + @if($step['id'] === 6 && $scenarioType === 'sales') + @include('sales.modals.partials.product-selection', ['tenant' => $tenant]) + @endif + + {{-- 단계 이동 버튼 --}} + @php + $currentStepId = (int) $step['id']; + $totalSteps = count($steps); + $isLastStep = ($currentStepId >= $totalSteps); + $nextStepId = $currentStepId + 1; + $prevStepId = $currentStepId - 1; + $stepColor = $step['color'] ?? 'blue'; + @endphp +
    + {{-- 이전 단계 버튼 --}} + @if($currentStepId > 1) + + @else +
    + @endif + + {{-- 다음 단계 / 완료 버튼 --}} + @if($isLastStep) + + @else + + @endif +
    +
    diff --git a/resources/views/sales/modals/voice-recorder.blade.php b/resources/views/sales/modals/voice-recorder.blade.php new file mode 100644 index 00000000..7ddeb7cf --- /dev/null +++ b/resources/views/sales/modals/voice-recorder.blade.php @@ -0,0 +1,399 @@ +{{-- 음성 녹음 컴포넌트 (자동 저장) --}} +
    + + {{-- 저장 중 오버레이 --}} +
    +
    + {{-- 아이콘 --}} +
    +
    + + + +
    +
    + {{-- 텍스트 --}} +

    + {{-- 프로그레스바 --}} +
    +
    +
    +

    잠시만 기다려주세요...

    +
    +
    + + {{-- 헤더 (접기/펼치기) --}} + + + {{-- 녹음 컨트롤 (접기/펼치기) --}} +
    + {{-- 파형 시각화 --}} +
    + + {{-- 타이머 오버레이 --}} +
    + + 00:00 +
    +
    + + {{-- 실시간 텍스트 변환 표시 --}} +
    +
    +

    음성 인식 결과

    +

    +
    +
    +

    + + +

    +
    +
    + + {{-- 녹음 버튼 --}} +
    + +

    +
    + + {{-- 안내 문구 --}} +

    + 녹음 종료 시 자동 저장됩니다 +

    +
    +
    diff --git a/resources/views/sales/products/index.blade.php b/resources/views/sales/products/index.blade.php new file mode 100644 index 00000000..8d4c7eef --- /dev/null +++ b/resources/views/sales/products/index.blade.php @@ -0,0 +1,418 @@ +@extends('layouts.app') + +@section('title', '상품관리') + +@section('content') +
    + {{-- 헤더 --}} +
    +
    +
    + + + +
    +
    +

    상품관리

    +

    SAM 솔루션 상품 및 요금 설정

    +
    +
    + +
    + + {{-- 카테고리 탭 --}} +
    +
    + +
    + + {{-- 상품 목록 영역 --}} +
    + {{-- 헤더 --}} +
    +
    + + (기본 제공: ) +
    + +
    + + {{-- 상품 카드 그리드 --}} +
    + @include('sales.products.partials.product-list', ['category' => $currentCategory]) +
    +
    +
    + + {{-- 상품 추가/수정 모달 --}} +
    + {{-- 배경 오버레이 (클릭해도 닫히지 않음) --}} +
    +
    +
    + +

    +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    +
    + + +

    기본: 개발비의 25%

    +
    +
    +
    + + +
    +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    +
    +
    + + +
    +
    +
    +
    +
    + + {{-- 카테고리 관리 모달 --}} +
    + {{-- 배경 오버레이 (클릭해도 닫히지 않음) --}} +
    +
    +
    +

    카테고리 관리

    +
    + @foreach($categories as $category) +
    +
    +
    {{ $category->name }}
    +
    {{ $category->code }} / {{ $category->base_storage }}
    +
    +
    + {{ $category->products->count() }}개 상품 +
    +
    + @endforeach +
    +
    +

    새 카테고리 추가

    +
    + + + +
    +
    + +
    +
    +
    +
    + +@push('scripts') + +@endpush +@endsection diff --git a/resources/views/sales/products/partials/product-list.blade.php b/resources/views/sales/products/partials/product-list.blade.php new file mode 100644 index 00000000..7477e397 --- /dev/null +++ b/resources/views/sales/products/partials/product-list.blade.php @@ -0,0 +1,84 @@ +@if($category && $category->products->count() > 0) + @foreach($category->products as $product) +
    + {{-- 헤더 --}} +
    +
    +
    +

    {{ $product->name }}

    + @if($product->is_required) + 필수 + @endif + @if(!$product->is_active) + 비활성 + @endif +
    +

    {{ $product->code }}

    +
    + +
    + + {{-- 설명 --}} + @if($product->description) +

    {{ $product->description }}

    + @endif + + {{-- 가격 정보 --}} +
    +
    + 가입비 +
    + {{ $product->formatted_development_fee }} + {{ $product->formatted_registration_fee }} +
    +
    +
    + 월 구독료 + {{ $product->formatted_subscription_fee }} +
    +
    + 수당 + + 파트너 {{ number_format($product->partner_commission_rate, 0) }}% + | + 매니저 {{ number_format($product->manager_commission_rate, 0) }}% + +
    +
    + + {{-- 하단 태그 --}} +
    +
    + @if($product->allow_flexible_pricing) + 재량권 허용 + @else + 고정가 + @endif +
    + +
    +
    + @endforeach +@else +
    +
    + + + +
    +

    등록된 상품이 없습니다

    +

    상품 추가 버튼을 클릭하여 새 상품을 등록하세요

    +
    +@endif diff --git a/resources/views/sales/prospects/create.blade.php b/resources/views/sales/prospects/create.blade.php index 2bc4cf55..da05dc78 100644 --- a/resources/views/sales/prospects/create.blade.php +++ b/resources/views/sales/prospects/create.blade.php @@ -99,11 +99,23 @@
    +

    명함 이미지를 드래그하거나 클릭하여 업로드

    AI가 자동으로 정보를 추출합니다 (JPG, PNG)

    + + +
    + +
    Preview
    @@ -112,8 +124,6 @@
    @csrf - -
    @@ -170,44 +180,6 @@ class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none foc class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
    -
    - - -

    JPG, PNG 형식 (최대 5MB)

    -
    - - -
    -

    추가 서류

    -
    -
    - -
    - - - - -

    클릭하여 업로드

    -
    - -
    - -
    - -
    - - - - -

    클릭하여 업로드

    -
    - -
    -
    -

    JPG, PNG 형식 (최대 5MB)

    -
    -
    +
    + + +
    +

    영업권 상태

    +
    +
    상태
    +
    + + {{ $prospect->status_label }} + +
    +
    등록일
    +
    {{ $prospect->registered_at->format('Y-m-d') }}
    +
    만료일
    +
    {{ $prospect->expires_at->format('Y-m-d') }}
    +
    등록자
    +
    {{ $prospect->registeredBy?->name ?? '-' }}
    +
    +
    + + +
    + + +
    +
    +
    diff --git a/resources/views/sales/prospects/partials/show-modal.blade.php b/resources/views/sales/prospects/partials/show-modal.blade.php new file mode 100644 index 00000000..d2ddc987 --- /dev/null +++ b/resources/views/sales/prospects/partials/show-modal.blade.php @@ -0,0 +1,150 @@ +{{-- 고객 상세 모달 내용 --}} +
    + +
    +
    +

    {{ $prospect->company_name }}

    +

    + + {{ $prospect->status_label }} + + {{ $prospect->business_number }} +

    +
    + +
    + +
    + +
    +

    회사 정보

    +
    +
    +
    사업자번호
    +
    {{ $prospect->business_number }}
    +
    +
    +
    회사명
    +
    {{ $prospect->company_name }}
    +
    +
    +
    대표자
    +
    {{ $prospect->ceo_name ?? '-' }}
    +
    +
    +
    연락처
    +
    {{ $prospect->contact_phone ?? '-' }}
    +
    +
    +
    이메일
    +
    {{ $prospect->contact_email ?? '-' }}
    +
    + @if($prospect->address) +
    +
    주소
    +
    {{ $prospect->address }}
    +
    + @endif +
    +
    + + +
    +

    영업권 정보

    +
    +
    +
    등록자
    +
    {{ $prospect->registeredBy?->name ?? '-' }}
    +
    +
    +
    등록일
    +
    {{ $prospect->registered_at->format('Y-m-d H:i') }}
    +
    +
    +
    만료일
    +
    + {{ $prospect->expires_at->format('Y-m-d') }} + @if($prospect->isActive()) + (D-{{ $prospect->remaining_days }}) + @endif +
    +
    + @if($prospect->isConverted()) +
    +
    계약일
    +
    {{ $prospect->converted_at?->format('Y-m-d') }}
    +
    +
    +
    계약 처리자
    +
    {{ $prospect->convertedBy?->name ?? '-' }}
    +
    + @endif +
    +
    +
    + + + @if($prospect->hasBusinessCard()) +
    +

    명함 이미지

    + + 명함 이미지 + +
    + @endif + + + @if($prospect->memo) +
    +

    메모

    +

    {{ $prospect->memo }}

    +
    + @endif + + +
    + @if($prospect->isActive()) +
    +

    + 영업권 유효: {{ $prospect->expires_at->format('Y-m-d') }}까지 (D-{{ $prospect->remaining_days }}) +

    +
    + @elseif($prospect->isConverted()) +
    +

    + 계약 완료: {{ $prospect->converted_at?->format('Y-m-d') }}에 계약되었습니다. +

    +
    + @elseif($prospect->isInCooldown()) +
    +

    + 재등록 대기: {{ $prospect->cooldown_ends_at->format('Y-m-d') }} 이후 재등록 가능 +

    +
    + @else +
    +

    + 영업권 만료: 재등록이 가능합니다. +

    +
    + @endif +
    + + +
    + + @if(!$prospect->isConverted()) + + @endif +
    +
    diff --git a/resources/views/sales/prospects/show.blade.php b/resources/views/sales/prospects/show.blade.php index 52b14e00..84e297c3 100644 --- a/resources/views/sales/prospects/show.blade.php +++ b/resources/views/sales/prospects/show.blade.php @@ -107,7 +107,7 @@ class="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg transitio
    -
    쿨다운 종료일
    +
    대기 종료일
    {{ $prospect->cooldown_ends_at->format('Y-m-d H:i') }}
    @if($prospect->isConverted()) @@ -137,39 +137,17 @@ class="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg transitio
    - @if($prospect->hasBusinessCard() || $prospect->hasIdCard() || $prospect->hasBankbook()) + @if($prospect->hasBusinessCard())

    첨부 서류

    -
    +
    - @if($prospect->hasBusinessCard())

    명함

    명함 이미지
    - @endif - - - @if($prospect->hasIdCard()) -
    -

    신분증 사본

    - - 신분증 이미지 - -
    - @endif - - - @if($prospect->hasBankbook()) -
    -

    통장 사본

    - - 통장사본 이미지 - -
    - @endif
    @endif @@ -194,7 +172,7 @@ class="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg transitio
    @elseif($prospect->isInCooldown())
    -

    쿨다운 기간

    +

    재등록 대기 기간

    영업권이 만료되었습니다. {{ $prospect->cooldown_ends_at->format('Y-m-d') }} 이후 다른 영업파트너가 재등록할 수 있습니다. @@ -211,7 +189,7 @@ class="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg transitio

    영업권 만료

    - 영업권이 만료되었습니다. 쿨다운 기간이 종료되어 재등록이 가능합니다. + 영업권이 만료되었습니다. 대기 기간이 종료되어 재등록이 가능합니다.

    @endif diff --git a/resources/views/system/ai-config/index.blade.php b/resources/views/system/ai-config/index.blade.php index 791a021f..4f0032ff 100644 --- a/resources/views/system/ai-config/index.blade.php +++ b/resources/views/system/ai-config/index.blade.php @@ -4,6 +4,9 @@ @push('styles')