commit 4782df3a506f003eedb03603e8626d8666761844 Author: hskwon Date: Wed Dec 17 12:59:26 2025 +0900 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cb4ed94 --- /dev/null +++ b/.gitignore @@ -0,0 +1,142 @@ +############################################ +# Laravel +############################################ +/vendor/ +/node_modules/ +/bootstrap/cache/ +/storage/*.key +/storage/app/* +/storage/framework/* +/storage/logs/* +!storage/.gitignore +.env +.env.* +.phpunit.result.cache +Homestead.yaml +Homestead.json +npm-debug.log +yarn-error.log +vite.config.js +vite.config.ts +public/storage +public/hot +public/mix-manifest.json +public/build/ + +/storage/pail/ +public/js/*.map +public/css/*.map + +############################################ +# IDE - PhpStorm +############################################ +.idea/ +/*.iml +*.iws +*.ipr + +############################################ +# IDE - VS Code +############################################ +.vscode/ + +############################################ +# IDE - Cursor AI +############################################ +.cursor/ + +############################################ +# OS & 에디터 임시 파일 +############################################ +.DS_Store +Thumbs.db +ehthumbs.db +desktop.ini +*.swp +*.swo +*.tmp +*.bak +*.old +*.orig + +############################################ +# 로그, 백업, 덤프 +############################################ +*.log +*.sql +*.sqlite +*.db +*.tar +*.gz +*.zip +*.7z +*.backup + +# 프로젝트 내 백업 폴더 +/backup/ +/backups/ + +############################################ +# 이미지, 문서, 동영상 등 업로드 제외 +*.jpg +*.jpeg +*.png +*.gif +*.bmp +*.svg +*.webp +*.ico + +*.pdf +*.doc +*.docx +*.xls +*.xlsx +*.ppt +*.pptx +*.hwp + +*.mp3 +*.wav +*.ogg +*.mp4 +*.avi +*.mov +*.wmv +*.mkv + +############################################ +# JetBrains Fleet / Laravel Nova / Zed IDE +############################################ +/.fleet/ +/.nova/ +/.zed/ + +############################################ +# PHP 도구 및 설정 파일 +############################################ +/.phpactor.json +/auth.json +/.phpunit.cache + + +############################################ +# 기타 +############################################ +.env.local +.env.backup +*.cache +*.coverage +*.out +*.pid +*.seed +*.seed.php +_ide_helper.php +_ide_helper_models.php + +# 모든 위치의 data 폴더 내부 파일 무시 +**/data/* +# 단, 폴더 자체는 추적 (비어 있어도 gitkeep을 위해) +!**/data/ +# 그리고 .gitkeep은 예외로 추적 +!**/data/.gitkeep diff --git a/index.php b/index.php new file mode 100644 index 0000000..6b85ea0 --- /dev/null +++ b/index.php @@ -0,0 +1,583 @@ + + + + + + + CodeBridge-X SAM - 영업관리 + + + + + + + + + +
+ +
+

다운로드 시작됨

+

CodeBridgeX_Proposal_v2.4.pdf

+
+
+ + + + + +
+
+
+
+ + CEO Management Solution +
+

+ 직원 관리 도구가 아닙니다.
+ + 대표님 경영 무기입니다. + +

+

+ "SAM"은 단순한 ERP가 아닙니다. + 가지급금 이자 계산부터 실시간 경영 알림까지.
+ 오직 CEO를 위한 시크릿 대시보드를 제안하십시오. +

+
+ + +
+ + +
+
+ SAM Project Dashboard +
+
+ +
+
+
+
+
+ + +
+
+
+
+
+
+ + +
+
+

영업 자료

+
+ +
+
+ + +
+ +
+ + +
+ + + + + + + + + + + + + diff --git a/lib/mydb.php b/lib/mydb.php new file mode 100644 index 0000000..4ed166f --- /dev/null +++ b/lib/mydb.php @@ -0,0 +1,37 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_EMULATE_PREPARES => false, + PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES utf8mb4, sql_mode='NO_ENGINE_SUBSTITUTION'" + ]); + } catch (PDOException $Exception) { + die('Error:'.$Exception->getMessage()); + } + return $pdo; +} +?> diff --git a/login/login_form.php b/login/login_form.php new file mode 100644 index 0000000..cd341a8 --- /dev/null +++ b/login/login_form.php @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + SAM Sales Login + + + + +
+
+
+
+
+
+

SAM Sales Portal

+
+
+ +
+
+
+
+
+ +
+ + + diff --git a/login/login_result.php b/login/login_result.php new file mode 100644 index 0000000..cfee3ee --- /dev/null +++ b/login/login_result.php @@ -0,0 +1,64 @@ +prepare($sql); + $stmh->bindValue(1, $id, PDO::PARAM_STR); + $stmh->execute(); + $count = $stmh->rowCount(); +} catch (PDOException $Exception) { + print "Error: " . $Exception->getMessage(); + exit; +} + +$row = $stmh->fetch(PDO::FETCH_ASSOC); + +if ($count < 1) { + ?> + + + + prepare($sql); + $stmh->bindValue(1, $data, PDO::PARAM_STR); + $stmh->execute(); + } catch (Throwable $e) { + // Ignore log error + } + + // Redirect to main page + header("Location: /index.php"); + exit; +} +?> diff --git a/login/logout.php b/login/logout.php new file mode 100644 index 0000000..9af9173 --- /dev/null +++ b/login/logout.php @@ -0,0 +1,7 @@ + + diff --git a/m4a/strategy.m4a b/m4a/strategy.m4a new file mode 100644 index 0000000..d04ca92 Binary files /dev/null and b/m4a/strategy.m4a differ diff --git a/sales_manager_scenario/api_handler.php b/sales_manager_scenario/api_handler.php new file mode 100644 index 0000000..e75a6db --- /dev/null +++ b/sales_manager_scenario/api_handler.php @@ -0,0 +1,67 @@ +prepare("SELECT step_id, checkpoint_index FROM manager_scenario_checklist WHERE tenant_id = :tenant_id AND is_checked = 1"); + $stmt->execute([':tenant_id' => $tenantId]); + $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); + + $data = []; + foreach ($rows as $row) { + if (!isset($data[$row['step_id']])) { + $data[$row['step_id']] = []; + } + $data[$row['step_id']][] = (int)$row['checkpoint_index']; + } + + echo json_encode(['success' => true, 'data' => $data]); + } catch (PDOException $e) { + echo json_encode(['success' => false, 'message' => $e->getMessage()]); + } +} +elseif ($method === 'POST') { + $input = json_decode(file_get_contents('php://input'), true); + + if (!$input) { + echo json_encode(['success' => false, 'message' => 'Invalid input']); + exit; + } + + $tenantId = $input['tenant_id'] ?? 'default_tenant'; + $stepId = $input['step_id']; + $checkpointIndex = $input['checkpoint_index']; + $isChecked = $input['is_checked'] ? 1 : 0; + + try { + if ($isChecked) { + // Insert or ignore (if already exists) + $stmt = $pdo->prepare("INSERT IGNORE INTO manager_scenario_checklist (tenant_id, step_id, checkpoint_index, is_checked) VALUES (:tenant_id, :step_id, :idx, 1)"); + $stmt->execute([':tenant_id' => $tenantId, ':step_id' => $stepId, ':idx' => $checkpointIndex]); + } else { + // Delete record if unchecked + $stmt = $pdo->prepare("DELETE FROM manager_scenario_checklist WHERE tenant_id = :tenant_id AND step_id = :step_id AND checkpoint_index = :idx"); + $stmt->execute([':tenant_id' => $tenantId, ':step_id' => $stepId, ':idx' => $checkpointIndex]); + } + + // Return updated list for this step + $stmt = $pdo->prepare("SELECT checkpoint_index FROM manager_scenario_checklist WHERE tenant_id = :tenant_id AND step_id = :step_id AND is_checked = 1"); + $stmt->execute([':tenant_id' => $tenantId, ':step_id' => $stepId]); + $checkedIndices = $stmt->fetchAll(PDO::FETCH_COLUMN); + + echo json_encode(['success' => true, 'data' => array_map('intval', $checkedIndices)]); + } catch (PDOException $e) { + echo json_encode(['success' => false, 'message' => $e->getMessage()]); + } +} +?> diff --git a/sales_manager_scenario/delete_consultation.php b/sales_manager_scenario/delete_consultation.php new file mode 100644 index 0000000..4aa070e --- /dev/null +++ b/sales_manager_scenario/delete_consultation.php @@ -0,0 +1,172 @@ + 'RS256', 'typ' => 'JWT'])); + $jwtClaim = base64_encode(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) { + error_log('GCS 삭제 실패: 개인 키 읽기 오류'); + return false; + } + + openssl_sign($jwtHeader . '.' . $jwtClaim, $signature, $privateKey, OPENSSL_ALGO_SHA256); + openssl_free_key($privateKey); + + $jwt = $jwtHeader . '.' . $jwtClaim . '.' . base64_encode($signature); + + // OAuth 토큰 요청 + $tokenCh = curl_init('https://oauth2.googleapis.com/token'); + curl_setopt($tokenCh, CURLOPT_POST, true); + curl_setopt($tokenCh, CURLOPT_POSTFIELDS, http_build_query([ + 'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer', + 'assertion' => $jwt + ])); + curl_setopt($tokenCh, CURLOPT_RETURNTRANSFER, true); + curl_setopt($tokenCh, CURLOPT_HTTPHEADER, ['Content-Type: application/x-www-form-urlencoded']); + + $tokenResponse = curl_exec($tokenCh); + $tokenCode = curl_getinfo($tokenCh, CURLINFO_HTTP_CODE); + curl_close($tokenCh); + + if ($tokenCode !== 200) { + error_log('GCS 삭제 실패: OAuth 토큰 요청 실패 (HTTP ' . $tokenCode . ')'); + return false; + } + + $tokenData = json_decode($tokenResponse, true); + if (!isset($tokenData['access_token'])) { + error_log('GCS 삭제 실패: OAuth 토큰 없음'); + return false; + } + + $accessToken = $tokenData['access_token']; + + // GCS에서 파일 삭제 + $delete_url = 'https://storage.googleapis.com/storage/v1/b/' . + urlencode($bucket_name) . '/o/' . + urlencode($object_name); + + $ch = curl_init($delete_url); + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE'); + curl_setopt($ch, CURLOPT_HTTPHEADER, [ + 'Authorization: Bearer ' . $accessToken + ]); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + + $response = curl_exec($ch); + $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + // 204 No Content 또는 404 Not Found는 성공으로 간주 + if ($code === 204 || $code === 404) { + return true; + } else { + error_log('GCS 삭제 실패 (HTTP ' . $code . '): ' . $response); + return false; + } +} + +// 출력 버퍼 비우기 +ob_clean(); + +header('Content-Type: application/json; charset=utf-8'); + +// 권한 체크 +if (!isset($user_id) || $level > 5) { + echo json_encode(['success' => false, 'message' => '접근 권한이 없습니다.']); + exit; +} + +// 업무협의 ID 확인 +$consultation_id = isset($_POST['id']) ? intval($_POST['id']) : 0; +$manager_id = $user_id; + +if ($consultation_id <= 0) { + echo json_encode(['success' => false, 'message' => '잘못된 요청입니다.']); + exit; +} + +try { + $pdo = db_connect(); + + // 녹음 파일 정보 조회 + $sql = "SELECT audio_file_path FROM manager_consultations WHERE id = ? AND manager_id = ?"; + $stmt = $pdo->prepare($sql); + $stmt->execute([$consultation_id, $manager_id]); + $consultation = $stmt->fetch(PDO::FETCH_ASSOC); + + if (!$consultation) { + echo json_encode(['success' => false, 'message' => '녹음 파일을 찾을 수 없습니다.']); + exit; + } + + // 1. 서버 파일 삭제 + if (!empty($consultation['audio_file_path'])) { + // GCS URI가 아닌 경우 로컬 파일 삭제 + if (strpos($consultation['audio_file_path'], 'gs://') !== 0) { + $file_path = $_SERVER['DOCUMENT_ROOT'] . $consultation['audio_file_path']; + $file_path = str_replace('\\', '/', $file_path); + $file_path = preg_replace('#/+#', '/', $file_path); + + if (file_exists($file_path)) { + @unlink($file_path); + } + } else { + // 2. GCS 파일 삭제 (GCS URI인 경우) + $gcs_uri = $consultation['audio_file_path']; + if (preg_match('#gs://([^/]+)/(.+)#', $gcs_uri, $matches)) { + $bucket_name = $matches[1]; + $object_name = $matches[2]; + + $gcs_deleted = deleteFromGCS($bucket_name, $object_name); + if (!$gcs_deleted) { + error_log('GCS 파일 삭제 실패: ' . $gcs_uri); + } + } + } + } + + // 3. DB에서 삭제 + $delete_sql = "DELETE FROM manager_consultations WHERE id = ? AND manager_id = ?"; + $delete_stmt = $pdo->prepare($delete_sql); + $delete_stmt->execute([$consultation_id, $manager_id]); + + echo json_encode(['success' => true, 'message' => '녹음 파일이 삭제되었습니다.']); + +} catch (Exception $e) { + error_log('녹음 파일 삭제 오류: ' . $e->getMessage()); + echo json_encode(['success' => false, 'message' => '삭제 중 오류가 발생했습니다: ' . $e->getMessage()]); +} +?> + diff --git a/sales_manager_scenario/download_consultation.php b/sales_manager_scenario/download_consultation.php new file mode 100644 index 0000000..2703eb8 --- /dev/null +++ b/sales_manager_scenario/download_consultation.php @@ -0,0 +1,113 @@ + 5) { + header('HTTP/1.0 403 Forbidden'); + die('접근 권한이 없습니다.'); +} + +// 녹음 파일 ID 확인 +$consultation_id = isset($_GET['id']) ? intval($_GET['id']) : 0; +$manager_id = $user_id; + +if ($consultation_id <= 0) { + header('HTTP/1.0 400 Bad Request'); + die('잘못된 요청입니다.'); +} + +try { + $pdo = db_connect(); + + $sql = "SELECT audio_file_path, created_at + FROM manager_consultations + WHERE id = ? AND manager_id = ?"; + $stmt = $pdo->prepare($sql); + $stmt->execute([$consultation_id, $manager_id]); + $consultation = $stmt->fetch(PDO::FETCH_ASSOC); + + if (!$consultation || empty($consultation['audio_file_path'])) { + header('HTTP/1.0 404 Not Found'); + die('오디오 파일을 찾을 수 없습니다.'); + } + + // GCS URI인 경우 처리 불가 (직접 다운로드 불가) + if (strpos($consultation['audio_file_path'], 'gs://') === 0) { + header('HTTP/1.0 400 Bad Request'); + die('GCS에 저장된 파일은 직접 다운로드할 수 없습니다.'); + } + + // 파일 경로 구성 + $file_path = $_SERVER['DOCUMENT_ROOT'] . $consultation['audio_file_path']; + + // 경로 정규화 + $file_path = str_replace('\\', '/', $file_path); + $file_path = preg_replace('#/+#', '/', $file_path); + + // 파일 존재 확인 + if (!file_exists($file_path)) { + header('HTTP/1.0 404 Not Found'); + die('오디오 파일이 서버에 존재하지 않습니다.'); + } + + // 파일 확장자 확인 + $file_extension = pathinfo($file_path, PATHINFO_EXTENSION) ?: 'webm'; + $mime_types = [ + 'webm' => 'audio/webm', + 'wav' => 'audio/wav', + 'mp3' => 'audio/mpeg', + 'ogg' => 'audio/ogg', + 'm4a' => 'audio/mp4' + ]; + + $content_type = isset($mime_types[$file_extension]) + ? $mime_types[$file_extension] + : 'audio/webm'; + + // 다운로드 파일명 생성 + $date = date('Ymd_His', strtotime($consultation['created_at'])); + $download_filename = '상담녹음_' . $date . '.' . $file_extension; + + // 출력 버퍼 비우기 + ob_clean(); + + $file_size = filesize($file_path); + if ($file_size === false || $file_size == 0) { + header('HTTP/1.0 500 Internal Server Error'); + die('파일을 읽을 수 없습니다.'); + } + + // 헤더 설정 + header('Content-Type: ' . $content_type); + header('Content-Disposition: attachment; filename="' . $download_filename . '"'); + header('Content-Length: ' . $file_size); + header('Content-Transfer-Encoding: binary'); + header('Cache-Control: must-revalidate, post-check=0, pre-check=0'); + header('Pragma: public'); + header('Expires: 0'); + + // 파일 출력 + $handle = @fopen($file_path, 'rb'); + if ($handle === false) { + header('HTTP/1.0 500 Internal Server Error'); + die('파일을 열 수 없습니다.'); + } + + while (!feof($handle)) { + $chunk = fread($handle, 8192); + if ($chunk === false) break; + echo $chunk; + flush(); + } + fclose($handle); + exit; + +} catch (Exception $e) { + header('HTTP/1.0 500 Internal Server Error'); + die('파일 다운로드 중 오류가 발생했습니다.'); +} \ No newline at end of file diff --git a/sales_manager_scenario/get_consultation_detail.php b/sales_manager_scenario/get_consultation_detail.php new file mode 100644 index 0000000..1121fbe --- /dev/null +++ b/sales_manager_scenario/get_consultation_detail.php @@ -0,0 +1,61 @@ + 5) { + echo json_encode(['success' => false, 'message' => '접근 권한이 없습니다.']); + exit; +} + +$consultation_id = isset($_GET['id']) ? intval($_GET['id']) : 0; +$manager_id = $user_id; + +if ($consultation_id <= 0) { + echo json_encode(['success' => false, 'message' => '잘못된 요청입니다.']); + exit; +} + +try { + $pdo = db_connect(); + + $sql = "SELECT id, manager_id, step_id, audio_file_path, transcript_text, + file_expiry_date, created_at + FROM manager_consultations + WHERE id = ? AND manager_id = ?"; + $stmt = $pdo->prepare($sql); + $stmt->execute([$consultation_id, $manager_id]); + $consultation = $stmt->fetch(PDO::FETCH_ASSOC); + + if (!$consultation) { + echo json_encode(['success' => false, 'message' => '녹음 파일을 찾을 수 없습니다.']); + exit; + } + + // 날짜 포맷팅 + $consultation['created_at_formatted'] = date('Y-m-d H:i:s', strtotime($consultation['created_at'])); + $consultation['is_gcs'] = strpos($consultation['audio_file_path'], 'gs://') === 0; + + echo json_encode([ + 'success' => true, + 'data' => $consultation + ]); + +} catch (Exception $e) { + error_log('조회 오류: ' . $e->getMessage()); + echo json_encode([ + 'success' => false, + 'message' => '조회 실패: ' . $e->getMessage() + ]); +} +?> + diff --git a/sales_manager_scenario/get_consultation_files.php b/sales_manager_scenario/get_consultation_files.php new file mode 100644 index 0000000..1ff09a8 --- /dev/null +++ b/sales_manager_scenario/get_consultation_files.php @@ -0,0 +1,68 @@ + 5) { + echo json_encode(['success' => false, 'message' => '접근 권한이 없습니다.']); + exit; +} + +$manager_id = $user_id; +$step_id = isset($_GET['step_id']) ? intval($_GET['step_id']) : 2; +$limit = isset($_GET['limit']) ? intval($_GET['limit']) : 20; + +try { + $pdo = db_connect(); + if (!$pdo) { + throw new Exception('데이터베이스 연결 실패'); + } + + // 저장된 첨부파일 목록 조회 + $sql = "SELECT id, manager_id, step_id, file_paths_json, + file_expiry_date, created_at + FROM manager_consultation_files + WHERE manager_id = ? AND step_id = ? + ORDER BY created_at DESC + LIMIT ?"; + $stmt = $pdo->prepare($sql); + + if (!$stmt) { + throw new Exception('SQL 준비 실패'); + } + + $stmt->execute([$manager_id, $step_id, $limit]); + $files = $stmt->fetchAll(PDO::FETCH_ASSOC); + + // JSON 파싱 및 날짜 포맷팅 + foreach ($files as &$file) { + $file['files'] = json_decode($file['file_paths_json'], true) ?: []; + $file['created_at_formatted'] = date('Y-m-d H:i', strtotime($file['created_at'])); + $file['file_count'] = count($file['files']); + } + + echo json_encode([ + 'success' => true, + 'data' => $files, + 'count' => count($files) + ]); + +} catch (Exception $e) { + error_log('조회 오류: ' . $e->getMessage()); + echo json_encode([ + 'success' => false, + 'message' => '조회 실패: ' . $e->getMessage(), + 'data' => [] + ]); +} +?> + diff --git a/sales_manager_scenario/get_consultations.php b/sales_manager_scenario/get_consultations.php new file mode 100644 index 0000000..2faa111 --- /dev/null +++ b/sales_manager_scenario/get_consultations.php @@ -0,0 +1,67 @@ + 5) { + echo json_encode(['success' => false, 'message' => '접근 권한이 없습니다.']); + exit; +} + +$manager_id = $user_id; +$step_id = isset($_GET['step_id']) ? intval($_GET['step_id']) : 2; +$limit = isset($_GET['limit']) ? intval($_GET['limit']) : 20; + +try { + $pdo = db_connect(); + if (!$pdo) { + throw new Exception('데이터베이스 연결 실패'); + } + + // 저장된 녹음 파일 목록 조회 + $sql = "SELECT id, manager_id, step_id, audio_file_path, transcript_text, + file_expiry_date, created_at + FROM manager_consultations + WHERE manager_id = ? AND step_id = ? + ORDER BY created_at DESC + LIMIT ?"; + $stmt = $pdo->prepare($sql); + + if (!$stmt) { + throw new Exception('SQL 준비 실패'); + } + + $stmt->execute([$manager_id, $step_id, $limit]); + $consultations = $stmt->fetchAll(PDO::FETCH_ASSOC); + + // 날짜 포맷팅 + foreach ($consultations as &$consultation) { + $consultation['created_at_formatted'] = date('Y-m-d H:i', strtotime($consultation['created_at'])); + $consultation['is_gcs'] = strpos($consultation['audio_file_path'], 'gs://') === 0; + } + + echo json_encode([ + 'success' => true, + 'data' => $consultations, + 'count' => count($consultations) + ]); + +} catch (Exception $e) { + error_log('조회 오류: ' . $e->getMessage()); + echo json_encode([ + 'success' => false, + 'message' => '조회 실패: ' . $e->getMessage(), + 'data' => [] + ]); +} +?> + diff --git a/sales_manager_scenario/index.php b/sales_manager_scenario/index.php new file mode 100644 index 0000000..3814a4a --- /dev/null +++ b/sales_manager_scenario/index.php @@ -0,0 +1,1630 @@ + + + + + + SAM 매니저 시나리오 - CodeBridgeExy + + + + + + + + + + + + + + + + + + + + + +
+ + + + diff --git a/sales_manager_scenario/save_consultation.php b/sales_manager_scenario/save_consultation.php new file mode 100644 index 0000000..442e354 --- /dev/null +++ b/sales_manager_scenario/save_consultation.php @@ -0,0 +1,218 @@ + 'RS256', 'typ' => 'JWT'])); + $jwtClaim = base64_encode(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) { + error_log('GCS 업로드 실패: 개인 키 읽기 오류'); + return false; + } + + openssl_sign($jwtHeader . '.' . $jwtClaim, $signature, $privateKey, OPENSSL_ALGO_SHA256); + openssl_free_key($privateKey); + + $jwt = $jwtHeader . '.' . $jwtClaim . '.' . base64_encode($signature); + + // OAuth 토큰 요청 + $tokenCh = curl_init('https://oauth2.googleapis.com/token'); + curl_setopt($tokenCh, CURLOPT_POST, true); + curl_setopt($tokenCh, CURLOPT_POSTFIELDS, http_build_query([ + 'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer', + 'assertion' => $jwt + ])); + curl_setopt($tokenCh, CURLOPT_RETURNTRANSFER, true); + curl_setopt($tokenCh, CURLOPT_HTTPHEADER, ['Content-Type: application/x-www-form-urlencoded']); + + $tokenResponse = curl_exec($tokenCh); + $tokenCode = curl_getinfo($tokenCh, CURLINFO_HTTP_CODE); + curl_close($tokenCh); + + if ($tokenCode !== 200) { + error_log('GCS 업로드 실패: OAuth 토큰 요청 실패 (HTTP ' . $tokenCode . ')'); + return false; + } + + $tokenData = json_decode($tokenResponse, true); + if (!isset($tokenData['access_token'])) { + error_log('GCS 업로드 실패: OAuth 토큰 없음'); + return false; + } + + $accessToken = $tokenData['access_token']; + + // GCS에 파일 업로드 + $file_content = file_get_contents($file_path); + $mime_type = mime_content_type($file_path) ?: 'audio/webm'; + + $upload_url = 'https://storage.googleapis.com/upload/storage/v1/b/' . + urlencode($bucket_name) . '/o?uploadType=media&name=' . + urlencode($object_name); + + $ch = curl_init($upload_url); + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_HTTPHEADER, [ + 'Authorization: Bearer ' . $accessToken, + 'Content-Type: ' . $mime_type, + 'Content-Length: ' . strlen($file_content) + ]); + curl_setopt($ch, CURLOPT_POSTFIELDS, $file_content); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + + $response = curl_exec($ch); + $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($code === 200) { + return 'gs://' . $bucket_name . '/' . $object_name; + } else { + error_log('GCS 업로드 실패 (HTTP ' . $code . '): ' . $response); + return false; + } +} + +// 출력 버퍼 비우기 +ob_clean(); + +header('Content-Type: application/json; charset=utf-8'); + +// 1. 권한 및 세션 체크 +if (!isset($user_id) || $level > 5) { + echo json_encode(['success' => false, 'message' => '접근 권한이 없습니다.']); + exit; +} + +$manager_id = $user_id; // session.php에서 user_id를 manager_id로 사용 +$upload_dir = $_SERVER['DOCUMENT_ROOT'] . "/uploads/manager_consultations/" . $manager_id . "/"; + +// 2. 파일 업로드 처리 +if (!file_exists($upload_dir)) mkdir($upload_dir, 0777, true); + +if (!isset($_FILES['audio_file'])) { + echo json_encode(['success' => false, 'message' => '오디오 파일이 없습니다.']); + exit; +} + +// 파일 크기 확인 +if ($_FILES['audio_file']['size'] == 0) { + echo json_encode(['success' => false, 'message' => '오디오 파일이 비어있습니다.']); + exit; +} + +$file_name = date('Ymd_His') . "_" . uniqid() . ".webm"; +$file_path = $upload_dir . $file_name; + +if (!move_uploaded_file($_FILES['audio_file']['tmp_name'], $file_path)) { + echo json_encode(['success' => false, 'message' => '파일 저장 실패']); + exit; +} + +// 3. GCS 업로드 (선택사항 - 파일이 큰 경우) +$gcs_uri = null; +$file_size = filesize($file_path); +$max_local_size = 10 * 1024 * 1024; // 10MB + +if ($file_size > $max_local_size) { + // GCS 설정 확인 + $gcs_config_file = $_SERVER['DOCUMENT_ROOT'] . '/apikey/gcs_config.txt'; + $bucket_name = null; + + if (file_exists($gcs_config_file)) { + $gcs_config = parse_ini_file($gcs_config_file); + $bucket_name = isset($gcs_config['bucket_name']) ? $gcs_config['bucket_name'] : null; + } + + if ($bucket_name) { + $googleServiceAccountFile = $_SERVER['DOCUMENT_ROOT'] . '/apikey/google_service_account.json'; + $gcs_object_name = 'manager_consultations/' . $manager_id . '/' . basename($file_path); + $gcs_uri = uploadToGCS($file_path, $bucket_name, $gcs_object_name, $googleServiceAccountFile); + + if ($gcs_uri) { + // GCS 업로드 성공 시 로컬 파일 삭제 (선택사항) + // @unlink($file_path); + } + } +} + +// 4. DB 저장 +$transcript = isset($_POST['transcript']) ? trim($_POST['transcript']) : ''; +$step_id = isset($_POST['step_id']) ? intval($_POST['step_id']) : 2; +$web_path = "/uploads/manager_consultations/" . $manager_id . "/" . $file_name; +$file_path_to_store = $gcs_uri ? $gcs_uri : $web_path; + +try { + $pdo = db_connect(); + if (!$pdo) { + throw new Exception('데이터베이스 연결 실패'); + } + + $expiry_date = date('Y-m-d H:i:s', strtotime('+30 days')); // 30일 보관 + + // manager_consultations 테이블에 저장 (테이블이 없으면 생성 필요) + $sql = "INSERT INTO manager_consultations + (manager_id, step_id, audio_file_path, transcript_text, file_expiry_date, created_at) + VALUES (?, ?, ?, ?, ?, NOW())"; + $stmt = $pdo->prepare($sql); + + if (!$stmt) { + throw new Exception('SQL 준비 실패'); + } + + $executeResult = $stmt->execute([ + $manager_id, + $step_id, + $file_path_to_store, + $transcript, + $expiry_date + ]); + + if (!$executeResult) { + throw new Exception('SQL 실행 실패'); + } + + $insertId = $pdo->lastInsertId(); + + echo json_encode([ + 'success' => true, + 'message' => '녹음 파일이 저장되었습니다.', + 'id' => $insertId, + 'file_path' => $file_path_to_store, + 'gcs_uri' => $gcs_uri + ]); + +} catch (Exception $e) { + error_log('DB 저장 오류: ' . $e->getMessage()); + echo json_encode([ + 'success' => false, + 'message' => '데이터베이스 저장 실패: ' . $e->getMessage() + ]); +} +?> + diff --git a/sales_manager_scenario/upload_consultation_files.php b/sales_manager_scenario/upload_consultation_files.php new file mode 100644 index 0000000..bb00897 --- /dev/null +++ b/sales_manager_scenario/upload_consultation_files.php @@ -0,0 +1,234 @@ + 'RS256', 'typ' => 'JWT'])); + $jwtClaim = base64_encode(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) { + error_log('GCS 업로드 실패: 개인 키 읽기 오류'); + return false; + } + + openssl_sign($jwtHeader . '.' . $jwtClaim, $signature, $privateKey, OPENSSL_ALGO_SHA256); + openssl_free_key($privateKey); + + $jwt = $jwtHeader . '.' . $jwtClaim . '.' . base64_encode($signature); + + // OAuth 토큰 요청 + $tokenCh = curl_init('https://oauth2.googleapis.com/token'); + curl_setopt($tokenCh, CURLOPT_POST, true); + curl_setopt($tokenCh, CURLOPT_POSTFIELDS, http_build_query([ + 'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer', + 'assertion' => $jwt + ])); + curl_setopt($tokenCh, CURLOPT_RETURNTRANSFER, true); + curl_setopt($tokenCh, CURLOPT_HTTPHEADER, ['Content-Type: application/x-www-form-urlencoded']); + + $tokenResponse = curl_exec($tokenCh); + $tokenCode = curl_getinfo($tokenCh, CURLINFO_HTTP_CODE); + curl_close($tokenCh); + + if ($tokenCode !== 200) { + error_log('GCS 업로드 실패: OAuth 토큰 요청 실패 (HTTP ' . $tokenCode . ')'); + return false; + } + + $tokenData = json_decode($tokenResponse, true); + if (!isset($tokenData['access_token'])) { + error_log('GCS 업로드 실패: OAuth 토큰 없음'); + return false; + } + + $accessToken = $tokenData['access_token']; + + // GCS에 파일 업로드 + $file_content = file_get_contents($file_path); + $mime_type = mime_content_type($file_path) ?: 'application/octet-stream'; + + $upload_url = 'https://storage.googleapis.com/upload/storage/v1/b/' . + urlencode($bucket_name) . '/o?uploadType=media&name=' . + urlencode($object_name); + + $ch = curl_init($upload_url); + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_HTTPHEADER, [ + 'Authorization: Bearer ' . $accessToken, + 'Content-Type: ' . $mime_type, + 'Content-Length: ' . strlen($file_content) + ]); + curl_setopt($ch, CURLOPT_POSTFIELDS, $file_content); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + + $response = curl_exec($ch); + $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($code === 200) { + return 'gs://' . $bucket_name . '/' . $object_name; + } else { + error_log('GCS 업로드 실패 (HTTP ' . $code . '): ' . $response); + return false; + } +} + +// 출력 버퍼 비우기 +ob_clean(); + +header('Content-Type: application/json; charset=utf-8'); + +// 1. 권한 및 세션 체크 +if (!isset($user_id) || $level > 5) { + echo json_encode(['success' => false, 'message' => '접근 권한이 없습니다.']); + exit; +} + +$manager_id = $user_id; +$step_id = isset($_POST['step_id']) ? intval($_POST['step_id']) : 2; +$upload_dir = $_SERVER['DOCUMENT_ROOT'] . "/uploads/manager_consultations/" . $manager_id . "/files/"; + +// 2. 파일 업로드 처리 +if (!file_exists($upload_dir)) mkdir($upload_dir, 0777, true); + +// 업로드된 파일 확인 +$uploaded_files = []; +$file_count = isset($_POST['file_count']) ? intval($_POST['file_count']) : 0; + +for ($i = 0; $i < $file_count; $i++) { + $file_key = 'file_' . $i; + + if (!isset($_FILES[$file_key])) { + continue; + } + + $file = $_FILES[$file_key]; + + // 파일 크기 확인 (50MB 제한) + $max_file_size = 50 * 1024 * 1024; + if ($file['size'] > $max_file_size) { + echo json_encode([ + 'success' => false, + 'message' => '파일 크기가 너무 큽니다. (최대 50MB): ' . $file['name'] + ]); + exit; + } + + // 파일명 생성 + $original_name = $file['name']; + $file_extension = pathinfo($original_name, PATHINFO_EXTENSION); + $file_name = date('Ymd_His') . "_" . uniqid() . "." . $file_extension; + $file_path = $upload_dir . $file_name; + + if (!move_uploaded_file($file['tmp_name'], $file_path)) { + echo json_encode(['success' => false, 'message' => '파일 저장 실패: ' . $original_name]); + exit; + } + + // GCS 업로드 (선택사항 - 큰 파일인 경우) + $gcs_uri = null; + $file_size = filesize($file_path); + $max_local_size = 10 * 1024 * 1024; // 10MB + + if ($file_size > $max_local_size) { + $gcs_config_file = $_SERVER['DOCUMENT_ROOT'] . '/apikey/gcs_config.txt'; + $bucket_name = null; + + if (file_exists($gcs_config_file)) { + $gcs_config = parse_ini_file($gcs_config_file); + $bucket_name = isset($gcs_config['bucket_name']) ? $gcs_config['bucket_name'] : null; + } + + if ($bucket_name) { + $googleServiceAccountFile = $_SERVER['DOCUMENT_ROOT'] . '/apikey/google_service_account.json'; + $gcs_object_name = 'manager_consultations/' . $manager_id . '/files/' . basename($file_path); + $gcs_uri = uploadToGCS($file_path, $bucket_name, $gcs_object_name, $googleServiceAccountFile); + } + } + + $web_path = "/uploads/manager_consultations/" . $manager_id . "/files/" . $file_name; + $file_path_to_store = $gcs_uri ? $gcs_uri : $web_path; + + $uploaded_files[] = [ + 'original_name' => $original_name, + 'file_path' => $file_path_to_store, + 'file_size' => $file_size, + 'gcs_uri' => $gcs_uri + ]; +} + +// 3. DB 저장 +try { + $pdo = db_connect(); + if (!$pdo) { + throw new Exception('데이터베이스 연결 실패'); + } + + $expiry_date = date('Y-m-d H:i:s', strtotime('+30 days')); + $file_paths_json = json_encode($uploaded_files); + + // manager_consultation_files 테이블에 저장 + $sql = "INSERT INTO manager_consultation_files + (manager_id, step_id, file_paths_json, file_expiry_date, created_at) + VALUES (?, ?, ?, ?, NOW())"; + $stmt = $pdo->prepare($sql); + + if (!$stmt) { + throw new Exception('SQL 준비 실패'); + } + + $executeResult = $stmt->execute([ + $manager_id, + $step_id, + $file_paths_json, + $expiry_date + ]); + + if (!$executeResult) { + throw new Exception('SQL 실행 실패'); + } + + $insertId = $pdo->lastInsertId(); + + echo json_encode([ + 'success' => true, + 'message' => count($uploaded_files) . '개 파일이 업로드되었습니다.', + 'id' => $insertId, + 'files' => $uploaded_files, + 'file_count' => count($uploaded_files) + ]); + +} catch (Exception $e) { + error_log('DB 저장 오류: ' . $e->getMessage()); + echo json_encode([ + 'success' => false, + 'message' => '데이터베이스 저장 실패: ' . $e->getMessage() + ]); +} +?> + diff --git a/sales_scenario/api_handler.php b/sales_scenario/api_handler.php new file mode 100644 index 0000000..634b7ef --- /dev/null +++ b/sales_scenario/api_handler.php @@ -0,0 +1,67 @@ +prepare("SELECT step_id, checkpoint_index FROM sales_scenario_checklist WHERE tenant_id = :tenant_id AND is_checked = 1"); + $stmt->execute([':tenant_id' => $tenantId]); + $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); + + $data = []; + foreach ($rows as $row) { + if (!isset($data[$row['step_id']])) { + $data[$row['step_id']] = []; + } + $data[$row['step_id']][] = (int)$row['checkpoint_index']; + } + + echo json_encode(['success' => true, 'data' => $data]); + } catch (PDOException $e) { + echo json_encode(['success' => false, 'message' => $e->getMessage()]); + } +} +elseif ($method === 'POST') { + $input = json_decode(file_get_contents('php://input'), true); + + if (!$input) { + echo json_encode(['success' => false, 'message' => 'Invalid input']); + exit; + } + + $tenantId = $input['tenant_id'] ?? 'default_tenant'; + $stepId = $input['step_id']; + $checkpointIndex = $input['checkpoint_index']; + $isChecked = $input['is_checked'] ? 1 : 0; + + try { + if ($isChecked) { + // Insert or ignore (if already exists) + $stmt = $pdo->prepare("INSERT IGNORE INTO sales_scenario_checklist (tenant_id, step_id, checkpoint_index, is_checked) VALUES (:tenant_id, :step_id, :idx, 1)"); + $stmt->execute([':tenant_id' => $tenantId, ':step_id' => $stepId, ':idx' => $checkpointIndex]); + } else { + // Delete record if unchecked + $stmt = $pdo->prepare("DELETE FROM sales_scenario_checklist WHERE tenant_id = :tenant_id AND step_id = :step_id AND checkpoint_index = :idx"); + $stmt->execute([':tenant_id' => $tenantId, ':step_id' => $stepId, ':idx' => $checkpointIndex]); + } + + // Return updated list for this step + $stmt = $pdo->prepare("SELECT checkpoint_index FROM sales_scenario_checklist WHERE tenant_id = :tenant_id AND step_id = :step_id AND is_checked = 1"); + $stmt->execute([':tenant_id' => $tenantId, ':step_id' => $stepId]); + $checkedIndices = $stmt->fetchAll(PDO::FETCH_COLUMN); + + echo json_encode(['success' => true, 'data' => array_map('intval', $checkedIndices)]); + } catch (PDOException $e) { + echo json_encode(['success' => false, 'message' => $e->getMessage()]); + } +} +?> diff --git a/sales_scenario/index.php b/sales_scenario/index.php new file mode 100644 index 0000000..67f7a85 --- /dev/null +++ b/sales_scenario/index.php @@ -0,0 +1,752 @@ + + + + + + SAM 영업 시나리오 - CodeBridgeExy + + + + + + + + + + + + + + + + + + + + + +
+ + + + diff --git a/sales_scenario/setup_db.php b/sales_scenario/setup_db.php new file mode 100644 index 0000000..9f4444b --- /dev/null +++ b/sales_scenario/setup_db.php @@ -0,0 +1,20 @@ +exec($sql); + echo "Table 'sales_scenario_checklist' created successfully."; +} catch (PDOException $e) { + echo "Error creating table: " . $e->getMessage(); +} +?> diff --git a/salesmanagement/api/company_info.php b/salesmanagement/api/company_info.php new file mode 100644 index 0000000..c9d89f2 --- /dev/null +++ b/salesmanagement/api/company_info.php @@ -0,0 +1,550 @@ + [ + 'sales_records' => [ + [ + "id" => "sale_001", + "customer_name" => "대박 식당", + "program_id" => "prog_pro", + "contract_date" => "2024-10-15", + "duration_months" => 84, + "join_fee" => 2000000, + "subscription_fee" => 100000, + "total_amount" => 2000000, + "status" => "Active", + "dates" => [ + "contract" => "2024-10-15", + "join_fee" => "2024-10-16", + "service_start" => "2024-11-01", + "subscription_fee" => "2024-11-25", + "product_modified" => "2024-10-15" + ], + "history" => [ + [ + "date" => "2024-10-15", + "type" => "New Contract", + "program_id" => "prog_pro", + "description" => "Initial contract signed (Pro Plan)" + ] + ] + ], + [ + "id" => "sale_002", + "customer_name" => "강남 카페", + "program_id" => "prog_premium", + "contract_date" => "2024-11-05", + "duration_months" => 84, + "join_fee" => 3000000, + "subscription_fee" => 150000, + "total_amount" => 3000000, + "status" => "Active", + "dates" => [ + "contract" => "2024-11-05", + "join_fee" => "2024-11-06", + "service_start" => "2024-12-01", + "subscription_fee" => "2024-12-25", + "product_modified" => "2024-11-05" + ], + "history" => [ + [ + "date" => "2024-11-05", + "type" => "New Contract", + "program_id" => "prog_premium", + "description" => "Initial contract signed (Premium Plan)" + ] + ] + ], + [ + "id" => "sale_003", + "customer_name" => "성수 팩토리", + "program_id" => "prog_basic", + "contract_date" => "2024-11-20", + "duration_months" => 84, + "join_fee" => 1000000, + "subscription_fee" => 50000, + "total_amount" => 1000000, + "status" => "Pending", + "dates" => [ + "contract" => "2024-11-20", + "join_fee" => null, + "service_start" => null, + "subscription_fee" => null, + "product_modified" => "2024-11-20" + ], + "history" => [ + [ + "date" => "2024-11-20", + "type" => "New Contract", + "program_id" => "prog_basic", + "description" => "Contract drafted (Basic Plan)" + ] + ] + ] + ], + 'current_user' => [ + "id" => "user_A", + "name" => "김관리 팀장", + "role" => "1차영업담당", + "sub_managers" => [ + [ + "id" => "user_B", + "name" => "이하위 대리", + "role" => "Sub-Manager", + "total_sales" => 50000000, + "active_contracts" => 5, + "performance_grade" => "A" + ], + [ + "id" => "user_C", + "name" => "박신입 사원", + "role" => "Seller", + "total_sales" => 12000000, + "active_contracts" => 2, + "performance_grade" => "B" + ], + [ + "id" => "user_D", + "name" => "최열정 인턴", + "role" => "Seller", + "total_sales" => 0, + "active_contracts" => 0, + "performance_grade" => "C" + ] + ] + ] + ], + '2차영업담당' => [ + 'sales_records' => [ + [ + "id" => "sale_201", + "customer_name" => "서초 IT센터", + "program_id" => "prog_pro", + "contract_date" => "2024-09-10", + "duration_months" => 84, + "join_fee" => 2000000, + "subscription_fee" => 100000, + "total_amount" => 2000000, + "status" => "Active", + "dates" => [ + "contract" => "2024-09-10", + "join_fee" => "2024-09-11", + "service_start" => "2024-10-01", + "subscription_fee" => "2024-10-25", + "product_modified" => "2024-09-10" + ], + "history" => [ + [ + "date" => "2024-09-10", + "type" => "New Contract", + "program_id" => "prog_pro", + "description" => "Initial contract signed (Pro Plan)" + ] + ] + ], + [ + "id" => "sale_202", + "customer_name" => "홍대 레스토랑", + "program_id" => "prog_basic", + "contract_date" => "2024-10-22", + "duration_months" => 84, + "join_fee" => 1000000, + "subscription_fee" => 50000, + "total_amount" => 1000000, + "status" => "Active", + "dates" => [ + "contract" => "2024-10-22", + "join_fee" => "2024-10-23", + "service_start" => "2024-11-01", + "subscription_fee" => "2024-11-25", + "product_modified" => "2024-10-22" + ], + "history" => [ + [ + "date" => "2024-10-22", + "type" => "New Contract", + "program_id" => "prog_basic", + "description" => "Initial contract signed (Basic Plan)" + ] + ] + ], + [ + "id" => "sale_203", + "customer_name" => "판교 스타트업", + "program_id" => "prog_premium", + "contract_date" => "2024-11-18", + "duration_months" => 84, + "join_fee" => 3000000, + "subscription_fee" => 150000, + "total_amount" => 3000000, + "status" => "Active", + "dates" => [ + "contract" => "2024-11-18", + "join_fee" => "2024-11-19", + "service_start" => "2024-12-01", + "subscription_fee" => "2024-12-25", + "product_modified" => "2024-11-18" + ], + "history" => [ + [ + "date" => "2024-11-18", + "type" => "New Contract", + "program_id" => "prog_premium", + "description" => "Initial contract signed (Premium Plan)" + ] + ] + ], + [ + "id" => "sale_204", + "customer_name" => "이태원 바", + "program_id" => "prog_basic", + "contract_date" => "2024-11-25", + "duration_months" => 84, + "join_fee" => 1000000, + "subscription_fee" => 50000, + "total_amount" => 1000000, + "status" => "Pending", + "dates" => [ + "contract" => "2024-11-25", + "join_fee" => null, + "service_start" => null, + "subscription_fee" => null, + "product_modified" => "2024-11-25" + ], + "history" => [ + [ + "date" => "2024-11-25", + "type" => "New Contract", + "program_id" => "prog_basic", + "description" => "Contract drafted (Basic Plan)" + ] + ] + ] + ], + 'current_user' => [ + "id" => "user_2A", + "name" => "정영업 과장", + "role" => "2차영업담당", + "sub_managers" => [ + [ + "id" => "user_2B", + "name" => "한성실 대리", + "role" => "Sub-Manager", + "total_sales" => 35000000, + "active_contracts" => 4, + "performance_grade" => "A" + ], + [ + "id" => "user_2C", + "name" => "윤열심 주임", + "role" => "Seller", + "total_sales" => 18000000, + "active_contracts" => 3, + "performance_grade" => "B" + ], + [ + "id" => "user_2D", + "name" => "강신규 사원", + "role" => "Seller", + "total_sales" => 5000000, + "active_contracts" => 1, + "performance_grade" => "C" + ] + ] + ] + ], + '3차영업담당' => [ + 'sales_records' => [ + [ + "id" => "sale_301", + "customer_name" => "잠실 마트", + "program_id" => "prog_basic", + "contract_date" => "2024-08-15", + "duration_months" => 84, + "join_fee" => 1000000, + "subscription_fee" => 50000, + "total_amount" => 1000000, + "status" => "Active", + "dates" => [ + "contract" => "2024-08-15", + "join_fee" => "2024-08-16", + "service_start" => "2024-09-01", + "subscription_fee" => "2024-09-25", + "product_modified" => "2024-08-15" + ], + "history" => [ + [ + "date" => "2024-08-15", + "type" => "New Contract", + "program_id" => "prog_basic", + "description" => "Initial contract signed (Basic Plan)" + ] + ] + ], + [ + "id" => "sale_302", + "customer_name" => "송파 병원", + "program_id" => "prog_pro", + "contract_date" => "2024-10-08", + "duration_months" => 84, + "join_fee" => 2000000, + "subscription_fee" => 100000, + "total_amount" => 2000000, + "status" => "Active", + "dates" => [ + "contract" => "2024-10-08", + "join_fee" => "2024-10-09", + "service_start" => "2024-11-01", + "subscription_fee" => "2024-11-25", + "product_modified" => "2024-10-08" + ], + "history" => [ + [ + "date" => "2024-10-08", + "type" => "New Contract", + "program_id" => "prog_pro", + "description" => "Initial contract signed (Pro Plan)" + ] + ] + ], + [ + "id" => "sale_303", + "customer_name" => "강동 학원", + "program_id" => "prog_basic", + "contract_date" => "2024-11-12", + "duration_months" => 84, + "join_fee" => 1000000, + "subscription_fee" => 50000, + "total_amount" => 1000000, + "status" => "Active", + "dates" => [ + "contract" => "2024-11-12", + "join_fee" => "2024-11-13", + "service_start" => "2024-12-01", + "subscription_fee" => "2024-12-25", + "product_modified" => "2024-11-12" + ], + "history" => [ + [ + "date" => "2024-11-12", + "type" => "New Contract", + "program_id" => "prog_basic", + "description" => "Initial contract signed (Basic Plan)" + ] + ] + ] + ], + 'current_user' => [ + "id" => "user_3A", + "name" => "오영업 대리", + "role" => "3차영업담당", + "sub_managers" => [ + [ + "id" => "user_3B", + "name" => "신성실 주임", + "role" => "Sub-Manager", + "total_sales" => 25000000, + "active_contracts" => 3, + "performance_grade" => "A" + ], + [ + "id" => "user_3C", + "name" => "조근면 사원", + "role" => "Seller", + "total_sales" => 8000000, + "active_contracts" => 2, + "performance_grade" => "B" + ], + [ + "id" => "user_3D", + "name" => "임신입 인턴", + "role" => "Seller", + "total_sales" => 2000000, + "active_contracts" => 1, + "performance_grade" => "C" + ] + ] + ] + ] +]; + +// 기본 데이터 구조 +$response = [ + "company_info" => [ + "id" => "comp_001", + "name" => "건축자재(주)", + "logo_url" => "https://via.placeholder.com/150x50?text=Company+Logo", // Placeholder + "currency" => "KRW" + ], + "sales_config" => [ + "programs" => [ + [ + "id" => "prog_basic", + "name" => "Basic Plan", + "join_fee" => 1000000, + "subscription_fee" => 50000, + "commission_rates" => [ + "seller" => ["join" => 0.2, "sub" => 0.5], + "manager" => ["join" => 0.05, "sub" => 0.3], + "educator" => ["join" => 0.03, "sub" => 0.2] + ] + ], + [ + "id" => "prog_pro", + "name" => "Pro Plan", + "join_fee" => 2000000, + "subscription_fee" => 100000, + "commission_rates" => [ + "seller" => ["join" => 0.2, "sub" => 0.5], + "manager" => ["join" => 0.05, "sub" => 0.3], + "educator" => ["join" => 0.03, "sub" => 0.2] + ] + ], + [ + "id" => "prog_premium", + "name" => "Premium Plan", + "join_fee" => 3000000, + "subscription_fee" => 150000, + "commission_rates" => [ + "seller" => ["join" => 0.2, "sub" => 0.5], + "manager" => ["join" => 0.05, "sub" => 0.3], + "educator" => ["join" => 0.03, "sub" => 0.2] + ] + ] + ], + "default_contract_period" => 84, + "package_types" => [ + [ + "id" => "select_models", + "name" => "선택모델", + "type" => "checkbox", + "models" => [ + [ + "id" => "model_qr", + "name" => "QR코드", + "sub_name" => "설비관리/장비 점검", + "join_fee" => 10200000, + "subscription_fee" => 50000, + "commission_rates" => [ + "seller" => ["join" => 0.2, "sub" => 0.5], + "manager" => ["join" => 0.05, "sub" => 0.3], + "educator" => ["join" => 0.03, "sub" => 0.2] + ] + ], + [ + "id" => "model_photo", + "name" => "사진 - 출하", + "sub_name" => "사진 관리", + "join_fee" => 19200000, + "subscription_fee" => 100000, + "commission_rates" => [ + "seller" => ["join" => 0.2, "sub" => 0.5], + "manager" => ["join" => 0.05, "sub" => 0.3], + "educator" => ["join" => 0.03, "sub" => 0.2] + ] + ], + [ + "id" => "model_inspection", + "name" => "검사 / 토큰 적용", + "sub_name" => "계산서 발행", + "join_fee" => 10200000, + "subscription_fee" => 50000, + "commission_rates" => [ + "seller" => ["join" => 0.2, "sub" => 0.5], + "manager" => ["join" => 0.05, "sub" => 0.3], + "educator" => ["join" => 0.03, "sub" => 0.2] + ] + ], + [ + "id" => "model_account", + "name" => "이카운트 / 거래처대장", + "sub_name" => "서류관리", + "join_fee" => 19200000, + "subscription_fee" => 100000, + "commission_rates" => [ + "seller" => ["join" => 0.2, "sub" => 0.5], + "manager" => ["join" => 0.05, "sub" => 0.3], + "educator" => ["join" => 0.03, "sub" => 0.2] + ] + ], + [ + "id" => "model_iso", + "name" => "ISO, 인정서류 / 토큰 적용", + "sub_name" => "서류관리", + "join_fee" => 10200000, + "subscription_fee" => 50000, + "commission_rates" => [ + "seller" => ["join" => 0.2, "sub" => 0.5], + "manager" => ["join" => 0.05, "sub" => 0.3], + "educator" => ["join" => 0.03, "sub" => 0.2] + ] + ], + [ + "id" => "model_inventory", + "name" => "적정 재고 표기", + "sub_name" => "재고 관리", + "join_fee" => 19200000, + "subscription_fee" => 100000, + "commission_rates" => [ + "seller" => ["join" => 0.2, "sub" => 0.5], + "manager" => ["join" => 0.05, "sub" => 0.3], + "educator" => ["join" => 0.03, "sub" => 0.2] + ] + ], + [ + "id" => "model_manufacturing", + "name" => "제조 관리", + "sub_name" => "제작지시, 발주", + "join_fee" => 19200000, + "subscription_fee" => 100000, + "commission_rates" => [ + "seller" => ["join" => 0.2, "sub" => 0.5], + "manager" => ["join" => 0.05, "sub" => 0.3], + "educator" => ["join" => 0.03, "sub" => 0.2] + ] + ] + ] + ], + [ + "id" => "construction_management", + "name" => "공사관리", + "type" => "package", + "join_fee" => 40000000, + "subscription_fee" => 200000, + "commission_rates" => [ + "seller" => ["join" => 0.25, "sub" => 0.5], + "manager" => ["join" => 0.05, "sub" => 0.3], + "educator" => ["join" => 0.03, "sub" => 0.2] + ] + ], + [ + "id" => "process_government", + "name" => "공정/정부지원사업", + "type" => "package", + "join_fee" => 80000000, + "subscription_fee" => 400000, + "commission_rates" => [ + "seller" => ["join" => 0.25, "sub" => 0.5], + "manager" => ["join" => 0.05, "sub" => 0.3], + "educator" => ["join" => 0.03, "sub" => 0.2] + ] + ] + ] + ], + "sales_records" => isset($roleData[$role]['sales_records']) ? $roleData[$role]['sales_records'] : [], + "current_user" => isset($roleData[$role]['current_user']) ? $roleData[$role]['current_user'] : [ + "id" => "user_default", + "name" => "기본 사용자", + "role" => $role, + "sub_managers" => [] + ] +]; + +echo json_encode($response, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); +?> diff --git a/salesmanagement/api/package_pricing.php b/salesmanagement/api/package_pricing.php new file mode 100644 index 0000000..95a1c67 --- /dev/null +++ b/salesmanagement/api/package_pricing.php @@ -0,0 +1,162 @@ +prepare("SELECT * FROM package_pricing WHERE is_active = 1 ORDER BY item_type, item_id"); + $stmt->execute(); + $items = $stmt->fetchAll(PDO::FETCH_ASSOC); + + // JSON 필드 파싱 + foreach ($items as &$item) { + if ($item['commission_rates']) { + $item['commission_rates'] = json_decode($item['commission_rates'], true); + } + $item['join_fee'] = floatval($item['join_fee']); + $item['subscription_fee'] = floatval($item['subscription_fee']); + $item['total_amount'] = $item['total_amount'] ? floatval($item['total_amount']) : null; + $item['allow_flexible_pricing'] = (bool)$item['allow_flexible_pricing']; + } + + echo json_encode(['success' => true, 'data' => $items], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); + } elseif ($action === 'get') { + // 단일 항목 조회 + $item_type = $_GET['item_type'] ?? ''; + $item_id = $_GET['item_id'] ?? ''; + + if (!$item_type || !$item_id) { + throw new Exception("item_type과 item_id가 필요합니다."); + } + + $stmt = $pdo->prepare("SELECT * FROM package_pricing WHERE item_type = ? AND item_id = ? AND is_active = 1"); + $stmt->execute([$item_type, $item_id]); + $item = $stmt->fetch(PDO::FETCH_ASSOC); + + if ($item) { + if ($item['commission_rates']) { + $item['commission_rates'] = json_decode($item['commission_rates'], true); + } + $item['join_fee'] = floatval($item['join_fee']); + $item['subscription_fee'] = floatval($item['subscription_fee']); + } + + echo json_encode(['success' => true, 'data' => $item], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); + } else { + throw new Exception("잘못된 action입니다."); + } + break; + + case 'POST': + // 새 항목 생성 + $data = json_decode(file_get_contents('php://input'), true); + + if (!isset($data['item_type']) || !isset($data['item_id']) || !isset($data['item_name'])) { + throw new Exception("필수 필드가 누락되었습니다."); + } + + $item_type = $data['item_type']; + $item_id = $data['item_id']; + $item_name = $data['item_name']; + $sub_name = $data['sub_name'] ?? null; + $join_fee = floatval($data['join_fee'] ?? 0); + $subscription_fee = floatval($data['subscription_fee'] ?? 0); + $commission_rates = isset($data['commission_rates']) ? json_encode($data['commission_rates'], JSON_UNESCAPED_UNICODE) : null; + + $stmt = $pdo->prepare(" + INSERT INTO package_pricing (item_type, item_id, item_name, sub_name, join_fee, subscription_fee, commission_rates) + VALUES (?, ?, ?, ?, ?, ?, ?) + "); + $stmt->execute([$item_type, $item_id, $item_name, $sub_name, $join_fee, $subscription_fee, $commission_rates]); + + echo json_encode(['success' => true, 'message' => '항목이 생성되었습니다.', 'id' => $pdo->lastInsertId()], JSON_UNESCAPED_UNICODE); + break; + + case 'PUT': + // 항목 수정 + $data = json_decode(file_get_contents('php://input'), true); + + if (!isset($data['item_type']) || !isset($data['item_id'])) { + throw new Exception("item_type과 item_id가 필요합니다."); + } + + $item_type = $data['item_type']; + $item_id = $data['item_id']; + $updates = []; + $params = []; + + if (isset($data['join_fee'])) { + $updates[] = "join_fee = ?"; + $params[] = floatval($data['join_fee']); + } + if (isset($data['subscription_fee'])) { + $updates[] = "subscription_fee = ?"; + $params[] = floatval($data['subscription_fee']); + } + if (isset($data['total_amount'])) { + $updates[] = "total_amount = ?"; + $params[] = $data['total_amount'] !== null ? floatval($data['total_amount']) : null; + } + if (isset($data['allow_flexible_pricing'])) { + $updates[] = "allow_flexible_pricing = ?"; + $params[] = intval($data['allow_flexible_pricing']); + } + if (isset($data['commission_rates'])) { + $updates[] = "commission_rates = ?"; + $params[] = json_encode($data['commission_rates'], JSON_UNESCAPED_UNICODE); + } + if (isset($data['item_name'])) { + $updates[] = "item_name = ?"; + $params[] = $data['item_name']; + } + if (isset($data['sub_name'])) { + $updates[] = "sub_name = ?"; + $params[] = $data['sub_name']; + } + + if (empty($updates)) { + throw new Exception("수정할 필드가 없습니다."); + } + + $params[] = $item_type; + $params[] = $item_id; + + $sql = "UPDATE package_pricing SET " . implode(", ", $updates) . " WHERE item_type = ? AND item_id = ?"; + $stmt = $pdo->prepare($sql); + $stmt->execute($params); + + echo json_encode(['success' => true, 'message' => '항목이 수정되었습니다.'], JSON_UNESCAPED_UNICODE); + break; + + case 'DELETE': + // 항목 삭제 (soft delete) + $item_type = $_GET['item_type'] ?? ''; + $item_id = $_GET['item_id'] ?? ''; + + if (!$item_type || !$item_id) { + throw new Exception("item_type과 item_id가 필요합니다."); + } + + $stmt = $pdo->prepare("UPDATE package_pricing SET is_active = 0 WHERE item_type = ? AND item_id = ?"); + $stmt->execute([$item_type, $item_id]); + + echo json_encode(['success' => true, 'message' => '항목이 삭제되었습니다.'], JSON_UNESCAPED_UNICODE); + break; + + default: + throw new Exception("지원하지 않는 HTTP 메서드입니다."); + } + +} catch (Exception $e) { + http_response_code(400); + echo json_encode(['success' => false, 'error' => $e->getMessage()], JSON_UNESCAPED_UNICODE); +} + diff --git a/salesmanagement/index.php b/salesmanagement/index.php new file mode 100644 index 0000000..b5d6c16 --- /dev/null +++ b/salesmanagement/index.php @@ -0,0 +1,2822 @@ + + + + + + 영업 관리 시스템 - CodeBridgeExy + + + + + + + + + + + + + + + + + + + +
+ + + + diff --git a/salesmanagement/sales_commission_ui_plan.md b/salesmanagement/sales_commission_ui_plan.md new file mode 100644 index 0000000..3beb13f --- /dev/null +++ b/salesmanagement/sales_commission_ui_plan.md @@ -0,0 +1,71 @@ +# CodeBridgeExy 영업 수당 체계 UI 계획서 + +## 1. 프로젝트 개요 +**프로젝트명**: CodeBridgeExy 영업 수당 관리 및 시뮬레이션 시스템 +**목적**: 엑셀로 관리되던 영업 수당 체계(`sample.xlsx`)를 웹 기반 UI로 전환하여, 영업 사원 및 관리자가 수당을 쉽게 계산하고 시뮬레이션하며, 실적을 관리할 수 있도록 함. + +## 2. 주요 기능 및 UI 구성 + +### 2.1 대시보드 (Dashboard) +* **개요**: 전체 영업 현황 및 수당 지급 현황을 한눈에 파악. +* **주요 지표 (KPI)**: + * 총 매출액 (Total Sales) + * 총 지급 수당 (Total Commission Paid) + * 이번 달 예상 수당 (Estimated Commission) + * 프로그램별 판매 비중 (Pie Chart) +* **디자인 컨셉**: Glassmorphism 카드 디자인, 다크/라이트 모드 지원, 동적 그래프 (Chart.js or Recharts). + +### 2.2 수당 시뮬레이터 (Commission Simulator) +* **기능**: 프로그램 유형과 조건을 입력하면 예상 수당을 자동으로 계산하여 보여줌. +* **입력 항목**: + * 프로그램 선택 (QR코드, 사진 관리 등) + * 계약 기간 (기본 7년/84개월) + * 가입비 및 구독료 설정 (기본값 자동 로드, 수정 가능) +* **출력 항목 (실시간 계산)**: + * **판매자 수당 (Seller)**: 가입비의 20% + 구독료의 50% + * **영업 관리자 수당 (Manager)**: 가입비의 5% + 구독료의 30% + * **교육 지원자 수당 (Educator)**: 가입비의 3% + 구독료의 20% + * **회사 마진 (Company Margin)**: 가입비의 70% 등 +* **UI 구성**: 좌측 입력 폼, 우측 결과 카드 (영수증 형태 또는 카드 형태). + +### 2.3 프로그램 및 수당 기준 관리 (Admin) +* **기능**: `sample.xlsx`의 기준 데이터를 관리 (CRUD). +* **데이터 테이블**: + * 프로그램명 + * 단가 + * 구독료 + * 수당 배분율 (판매자, 관리자, 교육자) +* **UI 구성**: 정렬 및 필터링이 가능한 데이터 그리드. + +## 3. 데이터 모델 (Data Model) +`sample.xlsx` 분석 기반: + +| 필드명 | 설명 | 예시 데이터 | +| :--- | :--- | :--- | +| `program_type` | 프로그램 타입 | QR코드, 사진 관리 | +| `unit_price` | 프로그램 단가 | 10,400,000 | +| `subscription_fee` | 월 구독료 | 100,000 | +| `duration_months` | 계약 기간 | 84 | +| `join_fee` | 가입비 | 2,000,000 | +| `commission_seller_join_rate` | 판매자 가입비 수당율 | 20% | +| `commission_seller_sub_rate` | 판매자 구독료 수당율 | 50% | +| `commission_manager_join_rate` | 관리자 가입비 수당율 | 5% | +| `commission_manager_sub_rate` | 관리자 구독료 수당율 | 30% | + +## 4. 기술 스택 (제안) +* **Frontend**: React (Next.js) +* **Styling**: Tailwind CSS (Premium Design, Responsive) +* **State Management**: Zustand or Context API +* **Charts**: Recharts or Chart.js +* **Icons**: Lucide React or Heroicons + +## 5. 디자인 가이드 (Aesthetics) +* **Color Palette**: 신뢰감을 주는 Deep Blue & Purple Gradients. +* **Typography**: Pretendard or Inter (가독성 최우선). +* **Interaction**: 버튼 호버 효과, 모달 등장 애니메이션, 수치 카운트업 효과. + +## 6. 개발 단계 +1. **기획 및 디자인**: 본 계획서 확정 및 와이어프레임 작성. +2. **프론트엔드 개발**: 컴포넌트 개발 및 시뮬레이션 로직 구현. +3. **데이터 연동**: (백엔드 필요 시) API 연동 또는 로컬 목업 데이터 사용. +4. **배포 및 테스트**. diff --git a/salesmanagement/sales_management_api_plan.md b/salesmanagement/sales_management_api_plan.md new file mode 100644 index 0000000..f3a69d3 --- /dev/null +++ b/salesmanagement/sales_management_api_plan.md @@ -0,0 +1,41 @@ +### 2.2 엔드포인트 +* **URL**: `/api/v1/company/sales-config` (예시) +* **Method**: `GET` +* **Headers**: + * `X-Tenant-ID`: `{company_id}` (또는 세션/토큰 기반 인증) + +### 2.3 응답 데이터 구조 (JSON) +```json +{ + "company_info": { + "id": "comp_12345", + "name": "건축자재(주)", +3. **Commission Simulator**: + * 프로그램 선택 Dropdown (API에서 로드된 프로그램 목록). + * 실시간 수당 계산기. +4. **Sales List & Detail Modal** (New): + * **Sales List**: API에서 가져온 `sales_records`를 테이블 형태로 표시. + * **Detail Modal**: + * **기본 정보**: 5가지 주요 일자(계약일, 가입비일, 구독료일, 시작일, 수정일) 표시. + * **수당 상세**: 현재 상품 기준 수당 계산. + * **히스토리**: 상품 변경 이력(타임라인) 표시. +5. **Sub-Manager Management** (New): + * **Hierarchy View**: 내 하위 관리자/영업사원 목록 표시. + * **Performance**: 하위 관리자의 실적(매출, 계약 건수) 요약 표시. + * **Detail Modal**: 하위 관리자 클릭 시, 상세 실적 요약(매출, 등급 등)을 모달로 표시. + +### 3.3 디자인 적용 (`tone.md` 준수) +* **Background**: `bg-gray-50` (rgb(250, 250, 250)) +* **Font**: `font-sans` (Pretendard) +* **Primary Color**: `text-blue-600`, `bg-blue-600` (버튼 등) +* **Cards**: `bg-white rounded-xl shadow-sm p-6` + +## 4. 개발 단계 +1. **API Mocking**: `api_mock.json` 또는 PHP 파일로 간단한 Mock API 구현. +2. **UI Skeleton**: `index.php`에 HTML 구조 및 Tailwind CSS 로드. +3. **Data Fetching**: `fetch()`를 사용하여 API에서 회사 정보 로드. +4. **Component Rendering**: 로드된 데이터를 기반으로 UI 렌더링. + +## 5. User Review Required +* **API 방식**: 별도의 백엔드 프레임워크 없이 PHP로 간단한 JSON 응답을 주는 API를 `api/company_info.php`와 같이 만들어서 테스트하시겠습니까? +* **Frontend 환경**: `index.php` 파일 하나에서 작업하려면 React CDN 방식이 간편합니다. Node.js 기반의 Next.js 프로젝트로 완전히 분리하시겠습니까? (현재 폴더 구조상 `index.php` 수정을 요청하셨으므로 **PHP + JS** 방식을 추천합니다.) diff --git a/salesmanagement/tone.md b/salesmanagement/tone.md new file mode 100644 index 0000000..9cfd423 --- /dev/null +++ b/salesmanagement/tone.md @@ -0,0 +1,73 @@ +# CodeBridgeExy Design Tone & Manner + +## 1. Overview +This document defines the visual style and design tokens extracted from the CodeBridgeExy Dashboard (`https://dev.codebridge-x.com/dashboard`). The design aims for a clean, modern, and professional look suitable for an enterprise ERP system. + +**Reference Screenshot**: +![Dashboard View](/Users/light/.gemini/antigravity/brain/9dc672eb-e699-4b4a-8834-f71fa81165a9/dashboard_view_1764110406679.png) + +## 2. Color Palette + +### Backgrounds +* **Page Background**: `rgb(250, 250, 250)` (Light Gray / Off-White) - Creates a clean canvas. +* **Card/Container Background**: `White` (`#ffffff`) or very light transparency - Used for content areas to separate them from the page background. + +### Text Colors +* **Headings**: `rgb(255, 255, 255)` (White) - Primarily used on dark sidebars or headers. +* **Body Text**: `oklch(0.551 0.027 264.364)` (Dark Slate Blue/Gray) - High contrast for readability on light backgrounds. +* **Muted Text**: Gray-400/500 (Inferred) - For secondary labels. + +### Brand Colors +* **Primary Blue**: (Inferred from "경영분석" button) - Likely a standard corporate blue (e.g., Tailwind `blue-600` or `indigo-600`). Used for primary actions and active states. + +## 3. Typography + +### Font Family +* **Primary**: `Pretendard` +* **Fallback**: `-apple-system, BlinkMacSystemFont, system-ui, Roboto, "Helvetica Neue", "Segoe UI", "Apple SD Gothic Neo", "Noto Sans KR", "Malgun Gothic", sans-serif` + +### Weights +* **Regular**: 400 (Body text) +* **Medium/Bold**: 500/700 (Headings, Buttons) + +## 4. UI Components & Layout + +### Cards +* **Border Radius**: `12px` (Rounded-lg/xl) - Soft, modern feel. +* **Shadow**: Subtle shadows (e.g., `shadow-sm` or `shadow-md`) to lift content off the background. +* **Padding**: Spacious internal padding to avoid clutter. + +### Buttons +* **Shape**: Rounded corners (matching cards, likely `8px` or `12px`). +* **Style**: Solid background for primary, outlined/ghost for secondary. + +### Layout Density +* **Spacious**: The design uses whitespace effectively to separate sections, typical of modern dashboards. + +## 5. Implementation Guide (Tailwind CSS) + +To match this tone in the new Sales Commission System: + +```js +// tailwind.config.js +module.exports = { + theme: { + extend: { + colors: { + background: 'rgb(250, 250, 250)', + foreground: 'oklch(0.551 0.027 264.364)', // Adjust to closest Hex + primary: { + DEFAULT: '#2563eb', // Placeholder for Brand Blue + foreground: '#ffffff', + }, + }, + fontFamily: { + sans: ['Pretendard', 'sans-serif'], + }, + borderRadius: { + 'card': '12px', + } + }, + }, +} +``` diff --git a/salesmanagement/수당지급체계.md b/salesmanagement/수당지급체계.md new file mode 100644 index 0000000..6e39739 --- /dev/null +++ b/salesmanagement/수당지급체계.md @@ -0,0 +1,584 @@ +# 영업관리 수당 지급 체계 + +## 개요 +이 문서는 영업관리 시스템의 수당 지급 체계를 정의합니다. +수당은 **가입비**에 대해서만 지급되며, 구독료에 대한 수당은 지급되지 않습니다. + +--- + +## 1. 기본 원칙 + +### 1.1 수당 지급 대상 +- **가입비만 수당 지급 대상** +- 구독료는 수당 계산에서 제외 + +### 1.2 계층 구조 +``` +내 조직 (영업관리자) +├── 내 직접 판매 (20%) +├── 1차 하위 (직속) (5%) +│ └── 2차 하위 (손자) (3%) +└── 1차 하위 (직속) (5%) +``` + +### 1.3 핵심 개념 +- **직접 판매**: 내가 직접 판매한 계약 +- **1차 하위 (관리자 수당)**: 내가 초대한 영업관리가 판매한 계약 +- **2차 하위 (교육자 수당)**: 내 하위의 하위가 판매한 계약 + +--- + +## 2. 수당 지급 비율 + +| 구분 | 지급 비율 | 설명 | +|------|-----------|------| +| **직접 판매** | 가입비의 **20%** | 본인이 직접 판매한 계약의 가입비 | +| **관리자 수당** | 가입비의 **5%** | 직속 하위(1차)가 판매한 계약의 가입비 | +| **교육자 수당** | 가입비의 **3%** | 2차 하위(손자)가 판매한 계약의 가입비 | + +**총 수당 = 직접 판매(20%) + 관리자 수당(5%) + 교육자 수당(3%)** + +--- + +## 3. 계층 구조 상세 설명 + +### 3.1 역할 정의 + +#### 판매자 (Seller) +- 직접 영업을 성사시킨 담당자 +- 자신의 판매에 대해 **20%** 수당 + +#### 관리자 (Manager) +- 판매자를 데려온 상위 담당자 +- 하위 1단계의 판매에 대해 **5%** 수당 + +#### 교육자 (Educator) +- 관리자를 데려온 상위 담당자 +- 하위 2단계의 판매에 대해 **3%** 수당 + +### 3.2 계층 예시 + +``` +A (영업관리자) +├── A의 직접 판매: 1,000만원 +├── B (1차 하위) +│ └── B의 판매: 2,000만원 +└── C (1차 하위) + ├── C의 판매: 1,500만원 + └── D (2차 하위) + └── D의 판매: 3,000만원 +``` + +**A가 받는 수당:** +- A의 직접 판매: 1,000만원 × 20% = **200만원** +- B의 판매 (관리자 수당): 2,000만원 × 5% = **100만원** +- C의 판매 (관리자 수당): 1,500만원 × 5% = **75만원** +- D의 판매 (교육자 수당): 3,000만원 × 3% = **90만원** +- **A의 총 수당: 465만원** + +--- + +## 4. 수당 계산 로직 + +### 4.1 내 총 수당 계산 공식 + +``` +내 총 수당 = (내 직접 판매 × 20%) + + (1차 하위 전체 판매 × 5%) + + (2차 하위 전체 판매 × 3%) +``` + +### 4.2 JavaScript 계산 코드 + +```javascript +// 내 직접 판매 수당 (20%) +const myDirectSales = findDirectSales(myOrg); +const sellerCommission = myDirectSales * 0.20; + +// 1차 하위 관리자 수당 (5%) +const level1Sales = calculateLevel1TotalSales(myOrg); +const managerCommission = level1Sales * 0.05; + +// 2차 하위 교육자 수당 (3%) +const level2Sales = calculateLevel2TotalSales(myOrg); +const educatorCommission = level2Sales * 0.03; + +// 총 수당 +const totalCommission = sellerCommission + managerCommission + educatorCommission; +``` + +--- + +## 5. 지급 일정 + +### 5.1 계약일 정의 +- **계약일**: 가입비 완료일을 기준으로 함 + +### 5.2 수당 지급일 +- **가입비 수당**: 가입비 완료 후 지급 +- 구독료 수당은 없음 (구독료는 수당 대상 아님) + +--- + +## 6. 회사 마진 + +### 6.1 마진 계산 +``` +회사 마진 = 가입비 - 총 수당 + = 가입비 - (20% + 5% + 3%) + = 가입비 × 72% +``` + +### 6.2 최대 수당 비율 +- 3단계 전체가 채워진 경우 최대 **28%** 수당 +- 나머지 **72%**는 회사 마진 + +--- + +## 7. 특수 상황 처리 + +### 7.1 하위가 없는 경우 +- 1차 하위가 없으면: 관리자 수당(5%) 없음 +- 2차 하위가 없으면: 교육자 수당(3%) 없음 +- 본인의 직접 판매(20%)만 받음 + +### 7.2 계약 취소/해지 +- 이미 지급된 가입비 수당은 회수하지 않음 +- 향후 정책 보완 예정 + +--- + +## 8. 대시보드 표시 구조 + +### 8.1 전체 누적 실적 +- **총 가입비**: 전체 기간 누적 가입비 +- **총 수당**: 전체 기간 누적 수당 +- **전체 건수**: 전체 계약 건수 + +### 8.2 기간별 실적 (당월 기본) +- **판매자 수당 (20%)**: 기간 내 직접 판매 수당 +- **관리자 수당 (5%)**: 기간 내 1차 하위 관리 수당 +- **교육자 수당 (3%)**: 기간 내 2차 하위 교육 수당 +- **총 수당**: 세 가지 수당의 합계 + +### 8.3 기간 선택 옵션 +- **당월**: 현재 년월 (기본값) +- **기간 설정**: 시작 년월 ~ 종료 년월 + +### 8.4 기간별 필터링 +- 각 계약의 `contractDate` 기준으로 필터링 +- 선택된 기간 내 계약만 집계 +- 조직 트리도 동일 기간으로 필터링 + +--- + +## 9. 조직 구조 표시 + +### 9.1 계층별 색상 +- **Depth 0** (내 조직): 파란색 +- **Depth 1** (1차 하위): 초록색 +- **Depth 2** (2차 하위): 보라색 +- **직접 판매**: 노란색 + +### 9.2 표시 정보 +각 노드마다 표시: +- 이름 및 역할 +- 총 매출 (가입비) +- 계약 건수 +- **내 수당**: 해당 노드의 판매로부터 내가 받는 수당 + +### 9.3 직접 판매 항목 +- 오직 **"내 조직"**만 직접 판매 항목을 가짐 +- 1차, 2차 영업관리는 자신의 판매가 자동으로 집계됨 +- 노란색 배경, 쇼핑카트 아이콘으로 구분 + +--- + +## 10. 데이터 구조 + +### 10.1 조직 노드 구조 +```javascript +{ + id: 'unique-id', + name: '김철수', + depth: 1, // 0: 내 조직, 1: 1차 하위, 2: 2차 하위 + role: '영업관리', + isDirect: false, // 직접 판매 항목 여부 + totalSales: 50000000, // 총 매출 (가입비) + contractCount: 15, + commission: 2500000, // 내가 받는 수당 + contracts: [ // 계약 목록 (날짜 포함) + { id: 'c1', contractDate: '2024-11-15', amount: 25000000 }, + { id: 'c2', contractDate: '2024-12-01', amount: 25000000 } + ], + children: [ /* 하위 노드 */ ] +} +``` + +### 10.2 직접 판매 노드 +```javascript +{ + id: 'root-direct', + name: '내 직접 판매', + depth: 0, + role: '직접 판매', + isDirect: true, + totalSales: 30000000, + contractCount: 3, + commission: 6000000, // 3천만원 × 20% + contracts: [ + { id: 'c1', contractDate: '2024-12-01', amount: 10000000 }, + { id: 'c2', contractDate: '2024-12-15', amount: 20000000 } + ], + children: [] +} +``` + +### 10.3 계약 데이터 구조 +```javascript +{ + id: 'contract-123', + contractDate: '2024-12-01', // YYYY-MM-DD 형식 + amount: 25000000 // 가입비 +} +``` + +--- + +## 11. 기간별 필터링 로직 + +### 11.1 필터링 알고리즘 +```javascript +filterNodeByDate(node, startDate, endDate) { + // 1. 해당 노드의 계약 중 기간 내 계약만 필터링 + const filteredContracts = node.contracts.filter(contract => { + const contractDate = new Date(contract.contractDate); + return contractDate >= startDate && contractDate <= endDate; + }); + + // 2. 하위 노드도 재귀적으로 필터링 + const filteredChildren = node.children + .map(child => filterNodeByDate(child, startDate, endDate)) + .filter(c => c !== null); + + // 3. 매출 재계산 + const ownSales = filteredContracts.reduce((sum, c) => sum + c.amount, 0); + const childrenSales = filteredChildren.reduce((sum, c) => sum + c.totalSales, 0); + const totalSales = ownSales + childrenSales; + + // 4. 데이터가 없으면 null 반환 (표시 안함) + if (totalSales === 0 && filteredChildren.length === 0) { + return null; + } + + // 5. 수당 재계산 + const commission = calculateCommission(node, ownSales, childrenSales); + + return { ...node, totalSales, contractCount, commission, contracts: filteredContracts, children: filteredChildren }; +} +``` + +### 11.2 기간 옵션 +- **당월**: `new Date(year, month, 1)` ~ `new Date(year, month+1, 0)` +- **커스텀**: 사용자가 선택한 시작일 ~ 종료일 + +--- + +## 12. 운영자 화면 - 영업담당 관리 + +### 12.1 개요 +운영자는 모든 영업담당의 실적과 수당을 역할별로 구분하여 확인할 수 있습니다. + +### 12.2 통계 카드 (6개) + +| 카드 | 설명 | 계산 방식 | +|------|------|-----------| +| **총 건수** | 전체 계약 건수 | 모든 영업담당의 계약 건수 합계 | +| **이번달 건수** | 이번달 신규 계약 | 당월 계약 건수 합계 | +| **총 가입비** | 전체 누적 가입비 | 모든 계약의 가입비 합계 | +| **총 수당 지급** | 전체 누적 수당 | 모든 영업담당에게 지급한 수당 합계 | +| **이번달 수당** | 이번달 지급 예정 | 당월 계약에 대한 수당 합계 | +| **지난달 수당** | 지난달 지급 완료 | 전월 계약에 대한 수당 합계 | + +### 12.3 영업담당 개별 카드 구조 + +각 영업담당 카드에 표시: +``` +김철수 [아이콘] +├── 총 건수: 15건 +├── 이번달 건수: 3건 +├── 총 가입비: ₩75,000,000 +├── └ 직접 판매 (20%): ₩6,000,000 +├── └ 관리자 (5%): ₩2,000,000 +├── └ 교육자 (3%): ₩900,000 +├── 총 수당: ₩8,900,000 +├── 이번달 수당: ₩1,200,000 +└── 지난달 수당: ₩1,500,000 +``` + +### 12.4 역할별 계약 데이터 구조 + +```javascript +{ + id: 'contract-123', + customer: '고객사 A', + contractDate: '2024-12-01', + amount: 25000000, + role: 'direct' // 'direct', 'manager', 'educator' +} +``` + +**역할 타입:** +- `direct`: 직접 판매 (20% 수당) +- `manager`: 1차 하위 관리 (5% 수당) +- `educator`: 2차 하위 교육 (3% 수당) + +### 12.5 세부 내역 화면 + +영업담당 클릭 시 표시: + +#### **역할별 수당 요약 (3개 카드)** + +| 직접 판매 (20%) | 관리자 수당 (5%) | 교육자 수당 (3%) | +|-----------------|-----------------|-----------------| +| 🟢 ₩6,042,670 | 🟣 ₩2,362,340 | 🟠 ₩914,256 | +| ₩30,213,350 × 20% | ₩47,246,800 × 5% | ₩30,475,200 × 3% | + +#### **계약 목록 테이블** + +| 번호 | 고객사 | 계약일 | 역할 | 가입비 | 수당 | +|------|--------|--------|------|--------|------| +| 1 | 고객사 A | 2024-11-15 | 🟢 직접 판매 (20%) | ₩25,000,000 | ₩5,000,000 | +| 2 | 고객사 B | 2024-12-01 | 🟣 관리자 (5%) | ₩30,000,000 | ₩1,500,000 | +| 3 | 고객사 C | 2024-10-20 | 🟠 교육자 (3%) | ₩20,000,000 | ₩600,000 | +| ... | ... | ... | ... | ... | ... | +| **합계** | | | | **₩107,689,150** | **₩9,477,266** | + +### 12.6 역할별 색상 구분 + +- 🟢 **초록색**: 직접 판매 (20%) +- 🟣 **보라색**: 관리자 (5%) +- 🟠 **주황색**: 교육자 (3%) + +### 12.7 계약 생성 로직 + +```javascript +// 각 영업담당마다 역할별 계약 생성 +const directContracts = generateContracts(2~6건); // 직접 판매 +const managerContracts = generateContracts(3~10건); // 1차 하위 +const educatorContracts = generateContracts(1~5건); // 2차 하위 + +// 역할 구분 +directContracts.forEach(c => c.role = 'direct'); +managerContracts.forEach(c => c.role = 'manager'); +educatorContracts.forEach(c => c.role = 'educator'); + +// 수당 계산 +const directCommission = directSales × 0.20; +const managerCommission = managerSales × 0.05; +const educatorCommission = educatorSales × 0.03; +const totalCommission = directCommission + managerCommission + educatorCommission; +``` + +--- + +## 12. 운영자 화면 - 영업담당 관리 + +### 12.1 개요 +운영자는 모든 영업담당의 실적과 수당을 역할별로 구분하여 확인할 수 있습니다. + +### 12.2 통계 카드 (6개) + +| 카드 | 설명 | 계산 방식 | +|------|------|-----------| +| **총 건수** | 전체 계약 건수 | 모든 영업담당의 계약 건수 합계 | +| **이번달 건수** | 이번달 신규 계약 | 당월 계약 건수 합계 | +| **총 가입비** | 전체 누적 가입비 | 모든 계약의 가입비 합계 | +| **총 수당 지급** | 전체 누적 수당 | 모든 영업담당에게 지급한 수당 합계 | +| **이번달 수당** | 이번달 지급 예정 | 당월 계약에 대한 수당 합계 | +| **지난달 수당** | 지난달 지급 완료 | 전월 계약에 대한 수당 합계 | + +### 12.3 영업담당 개별 카드 구조 + +각 영업담당 카드에 표시: +``` +김철수 [아이콘] +├── 총 건수: 15건 +├── 이번달 건수: 3건 +├── 총 가입비: ₩75,000,000 +├── └ 직접 판매 (20%): ₩6,000,000 +├── └ 관리자 (5%): ₩2,000,000 +├── └ 교육자 (3%): ₩900,000 +├── 총 수당: ₩8,900,000 +├── 이번달 수당: ₩1,200,000 +└── 지난달 수당: ₩1,500,000 +``` + +### 12.4 역할별 계약 데이터 구조 + +```javascript +{ + id: 'contract-123', + customer: '고객사 A', + contractDate: '2024-12-01', + amount: 25000000, + role: 'direct' // 'direct', 'manager', 'educator' +} +``` + +**역할 타입:** +- `direct`: 직접 판매 (20% 수당) +- `manager`: 1차 하위 관리 (5% 수당) +- `educator`: 2차 하위 교육 (3% 수당) + +### 12.5 세부 내역 화면 + +영업담당 클릭 시 표시: + +#### **역할별 수당 요약 (3개 카드)** + +| 직접 판매 (20%) | 관리자 수당 (5%) | 교육자 수당 (3%) | +|-----------------|-----------------|-----------------| +| 🟢 ₩6,042,670 | 🟣 ₩2,362,340 | 🟠 ₩914,256 | +| ₩30,213,350 × 20% | ₩47,246,800 × 5% | ₩30,475,200 × 3% | + +#### **계약 목록 테이블** + +| 번호 | 고객사 | 계약일 | 역할 | 가입비 | 수당 | +|------|--------|--------|------|--------|------| +| 1 | 고객사 A | 2024-11-15 | 🟢 직접 판매 (20%) | ₩25,000,000 | ₩5,000,000 | +| 2 | 고객사 B | 2024-12-01 | 🟣 관리자 (5%) | ₩30,000,000 | ₩1,500,000 | +| 3 | 고객사 C | 2024-10-20 | 🟠 교육자 (3%) | ₩20,000,000 | ₩600,000 | +| ... | ... | ... | ... | ... | ... | +| **합계** | | | | **₩107,689,150** | **₩9,477,266** | + +### 12.6 역할별 색상 구분 + +- 🟢 **초록색**: 직접 판매 (20%) +- 🟣 **보라색**: 관리자 (5%) +- 🟠 **주황색**: 교육자 (3%) + +### 12.7 계약 생성 로직 + +```javascript +// 각 영업담당마다 역할별 계약 생성 +const directContracts = generateContracts(2~6건); // 직접 판매 +const managerContracts = generateContracts(3~10건); // 1차 하위 +const educatorContracts = generateContracts(1~5건); // 2차 하위 + +// 역할 구분 +directContracts.forEach(c => c.role = 'direct'); +managerContracts.forEach(c => c.role = 'manager'); +educatorContracts.forEach(c => c.role = 'educator'); + +// 수당 계산 +const directCommission = directSales × 0.20; +const managerCommission = managerSales × 0.05; +const educatorCommission = educatorSales × 0.03; +const totalCommission = directCommission + managerCommission + educatorCommission; +``` + +--- + +## 13. API 연동 (향후 구현) + +### 13.1 필요한 API 엔드포인트 +``` +GET /api/sales/organization?startDate=2024-12-01&endDate=2024-12-31 +GET /api/operator/managers?startDate=2024-12-01&endDate=2024-12-31 +``` + +### 13.2 응답 데이터 구조 +위의 "10. 데이터 구조"와 동일한 형식으로 반환 + +--- + +## 14. 개발 노트 + +### 14.1 구현 완료 +- ✅ 가입비 기반 수당 계산 +- ✅ 3단계 계층 구조 (직접/1차/2차) +- ✅ 계약 날짜 기반 기간 필터링 +- ✅ 대시보드 통계 연동 +- ✅ 역할별 수당 상세 표시 +- ✅ 계약 날짜 데이터 구조 추가 +- ✅ 운영자 화면 영업담당 관리 +- ✅ 역할별 계약 구분 (직접/관리자/교육자) +- ✅ 영업담당 세부 내역 역할별 표시 +- ✅ 모달창 계약 상세 내역 및 날짜 표시 + +### 14.2 향후 개선 사항 +- [ ] 실제 DB 연동 +- [ ] 계약 취소/해지 처리 로직 +- [ ] 수당 지급 이력 관리 +- [ ] 월별 수당 지급 스케줄 +- [ ] 세금 공제 계산 +- [ ] 운영자 화면 실시간 데이터 연동 +- [ ] 영업담당별 성과 분석 리포트 + +### 14.3 주의사항 +- 현재는 샘플 랜덤 데이터 사용 (최근 12개월 내 랜덤 날짜) +- 실제 구현 시 DB의 계약 데이터 기반으로 변경 필요 +- PHP 7.3 호환성 유지 +- 운영자 화면은 각 영업담당의 역할별 계약을 모두 표시 +- 각 영업담당은 직접 판매, 관리자, 교육자 역할을 동시에 수행 가능 + +--- + +## 15. 변경 이력 + +| 날짜 | 버전 | 변경 내용 | +|------|------|-----------| +| 2024-12-02 | 1.0 | 초기 문서 작성 - 가입비 기반 수당 체계 정의 | +| 2024-12-02 | 1.1 | 계약 날짜 구조 추가 - 기간별 필터링 로직 문서화 | +| 2024-12-02 | 1.2 | 운영자 화면 추가 - 역할별 계약 구분 및 수당 관리 상세화 | + +--- + +## 16. 운영자 화면 계산 예시 + +### 16.1 영업담당 "김철수"의 데이터 + +**역할별 계약:** +- 직접 판매: 3건, 총 ₩30,000,000 +- 관리자 역할: 8건, 총 ₩100,000,000 (1차 하위의 판매) +- 교육자 역할: 5건, 총 ₩50,000,000 (2차 하위의 판매) + +**수당 계산:** +``` +직접 판매 수당 = ₩30,000,000 × 20% = ₩6,000,000 +관리자 수당 = ₩100,000,000 × 5% = ₩5,000,000 +교육자 수당 = ₩50,000,000 × 3% = ₩1,500,000 +─────────────────────────────────────────────── +총 수당 = ₩12,500,000 +``` + +**카드 표시:** +``` +김철수 +├── 총 건수: 16건 +├── 이번달 건수: 3건 +├── 총 가입비: ₩180,000,000 +├── └ 직접 판매 (20%): ₩6,000,000 +├── └ 관리자 (5%): ₩5,000,000 +├── └ 교육자 (3%): ₩1,500,000 +└── 총 수당: ₩12,500,000 +``` + +### 16.2 세부 내역 테이블 예시 + +| 번호 | 고객사 | 계약일 | 역할 | 가입비 | 수당 | +|------|--------|--------|------|--------|------| +| 1 | 고객사 A | 2024-11-15 | 직접 판매 (20%) | ₩10,000,000 | ₩2,000,000 | +| 2 | 고객사 B | 2024-12-01 | 직접 판매 (20%) | ₩20,000,000 | ₩4,000,000 | +| 3 | 고객사 C | 2024-10-20 | 관리자 (5%) | ₩25,000,000 | ₩1,250,000 | +| 4 | 고객사 D | 2024-11-03 | 관리자 (5%) | ₩35,000,000 | ₩1,750,000 | +| 5 | 고객사 E | 2024-09-15 | 교육자 (3%) | ₩20,000,000 | ₩600,000 | +| 6 | 고객사 F | 2024-12-10 | 교육자 (3%) | ₩30,000,000 | ₩900,000 | +| **합계** | | | | **₩180,000,000** | **₩12,500,000** | + +--- + +## 문의 및 개선 제안 +개발 과정에서 수당 체계 관련 질문이나 개선 제안 사항이 있으면 이 문서를 업데이트하여 관리합니다. + diff --git a/session.php b/session.php new file mode 100644 index 0000000..dbfdd6e --- /dev/null +++ b/session.php @@ -0,0 +1,27 @@ +