docs:mng/claudedocs 가이드 문서들 guides 폴더로 이동
이동된 파일: - 2025-12-02_file-attachment-feature.md - ai-config-설정.md - archive-restore-feature-analysis.md - barobill-members-migration.md - super-admin-protection.md - 명함추출로직.md - 모달창_생성시_유의사항.md - 상품관리정보.md - 수당지급.md - 영업파트너구조.md - 홈택스 매입매출 조회성공.md Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
163
guides/2025-12-02_file-attachment-feature.md
Normal file
163
guides/2025-12-02_file-attachment-feature.md
Normal file
@@ -0,0 +1,163 @@
|
||||
# 게시글 파일 첨부 기능 구현 + 공유 스토리지 설정
|
||||
|
||||
**작업일**: 2025-12-02
|
||||
**저장소**: MNG, API, Docker
|
||||
**워크플로우**: code-workflow (분석→수정→검증→정리→커밋)
|
||||
|
||||
---
|
||||
|
||||
## 개요
|
||||
|
||||
게시판 시스템에 파일 첨부 기능을 추가했습니다. 기존의 `board_files` 테이블 대신 범용 `files` 테이블의 polymorphic 관계를 활용합니다.
|
||||
|
||||
**추가 작업**: API와 MNG 간 파일 공유를 위한 Docker 공유 볼륨 설정 및 S3 마이그레이션 용이한 구조로 변경
|
||||
|
||||
## 변경 파일
|
||||
|
||||
### Docker 설정
|
||||
| 파일 | 작업 | 설명 |
|
||||
|------|------|------|
|
||||
| `docker/docker-compose.yml` | 수정 | sam_storage 공유 볼륨 추가 (api, admin, mng) |
|
||||
|
||||
### API 저장소
|
||||
| 파일 | 작업 | 설명 |
|
||||
|------|------|------|
|
||||
| `config/filesystems.php` | 수정 | tenant 디스크 공유 경로 + S3 설정 |
|
||||
| `database/migrations/2025_12_02_000238_drop_board_files_table.php` | 생성 | board_files 테이블 삭제 |
|
||||
|
||||
### MNG 저장소
|
||||
| 파일 | 작업 | 설명 |
|
||||
|------|------|------|
|
||||
| `app/Models/Boards/File.php` | 생성 | Polymorphic 파일 모델 |
|
||||
| `app/Models/Boards/Post.php` | 수정 | files() MorphMany 관계 추가 |
|
||||
| `app/Services/PostService.php` | 수정 | 파일 업로드/삭제/다운로드 + 경로 패턴 수정 |
|
||||
| `app/Http/Controllers/PostController.php` | 수정 | 파일 관련 액션 추가 |
|
||||
| `resources/views/posts/create.blade.php` | 수정 | 파일 업로드 UI |
|
||||
| `resources/views/posts/show.blade.php` | 수정 | 첨부파일 목록 표시 |
|
||||
| `resources/views/posts/edit.blade.php` | 수정 | 기존 파일 관리 + 새 파일 업로드 |
|
||||
| `routes/web.php` | 수정 | 파일 라우트 추가 |
|
||||
| `config/filesystems.php` | 수정 | tenant 디스크 공유 경로 + S3 설정 |
|
||||
| `storage/app/tenants/.gitignore` | 생성 | 업로드 파일 Git 제외 |
|
||||
|
||||
## 기술 상세
|
||||
|
||||
### Polymorphic 관계
|
||||
```php
|
||||
// Post -> files() MorphMany
|
||||
$post->files; // Collection of File models
|
||||
|
||||
// File -> fileable() MorphTo
|
||||
$file->fileable; // Returns Post model
|
||||
```
|
||||
|
||||
### 파일 저장 경로 (공유 스토리지)
|
||||
```
|
||||
Docker 볼륨: sam_storage → /var/www/shared-storage
|
||||
실제 경로: /var/www/shared-storage/tenants/{tenant_id}/posts/{year}/{month}/{stored_name}
|
||||
DB 저장: {tenant_id}/posts/{year}/{month}/{stored_name}
|
||||
```
|
||||
|
||||
### 공유 스토리지 아키텍처
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Docker Volume: sam_storage │
|
||||
│ /var/www/shared-storage/tenants │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
|
||||
│ │ API │ │ MNG │ │ Admin │ │
|
||||
│ │Container│ │Container│ │Container│ │
|
||||
│ └────┬────┘ └────┬────┘ └────┬────┘ │
|
||||
│ │ │ │ │
|
||||
│ └────────────────┴────────────────┘ │
|
||||
│ │ │
|
||||
│ Storage::disk('tenant') │
|
||||
│ │ │
|
||||
│ ┌─────────────────────┴─────────────────────┐ │
|
||||
│ │ /var/www/shared-storage/tenants │ │
|
||||
│ │ ├── {tenant_id}/ │ │
|
||||
│ │ │ ├── posts/2025/12/xxx.pdf │ │
|
||||
│ │ │ ├── products/2025/12/yyy.jpg │ │
|
||||
│ │ │ └── documents/2025/12/zzz.docx │ │
|
||||
│ └────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### S3 마이그레이션 방법
|
||||
```bash
|
||||
# .env 설정 변경만으로 S3 전환 가능
|
||||
TENANT_STORAGE_DRIVER=s3
|
||||
AWS_ACCESS_KEY_ID=your_key
|
||||
AWS_SECRET_ACCESS_KEY=your_secret
|
||||
AWS_DEFAULT_REGION=ap-northeast-2
|
||||
AWS_BUCKET=sam-storage
|
||||
```
|
||||
|
||||
### 게시판 설정
|
||||
- `allow_files`: 파일 첨부 허용 여부
|
||||
- `max_file_count`: 최대 파일 개수
|
||||
- `max_file_size`: 최대 파일 크기 (KB)
|
||||
|
||||
### 새 라우트
|
||||
```
|
||||
GET boards/{board}/posts/{post}/files/{fileId}/download # 다운로드
|
||||
POST boards/{board}/posts/{post}/files # 업로드 (AJAX)
|
||||
DELETE boards/{board}/posts/{post}/files/{fileId} # 삭제 (AJAX)
|
||||
```
|
||||
|
||||
### File 모델 주요 메서드
|
||||
- `fileable()`: Polymorphic 관계
|
||||
- `download()`: StreamedResponse 반환
|
||||
- `getFormattedSize()`: 사람이 읽기 쉬운 파일 크기
|
||||
- `isImage()`: 이미지 파일 여부
|
||||
- `permanentDelete()`: 실제 파일 + DB 레코드 삭제
|
||||
|
||||
### PostService 주요 메서드
|
||||
- `uploadFiles(Post, array)`: 파일 업로드 및 저장
|
||||
- `deleteFile(Post, fileId)`: 파일 소프트 삭제
|
||||
- `downloadFile(Post, fileId)`: 파일 다운로드 응답
|
||||
|
||||
## UI 기능
|
||||
|
||||
### 글쓰기 (create.blade.php)
|
||||
- 드래그앤드롭 파일 업로드 영역 ✅
|
||||
- 파일 선택 시 미리보기 목록
|
||||
- 파일 개수/크기 제한 클라이언트 검증
|
||||
- **드래그앤드롭 JS 이벤트** (dragenter, dragover, dragleave, drop)
|
||||
- **시각적 피드백** (드래그 시 테두리 파란색 변경)
|
||||
|
||||
### 글보기 (show.blade.php)
|
||||
- 첨부파일 섹션 (파일 있을 때만 표시)
|
||||
- 이미지/문서 아이콘 구분
|
||||
- 다운로드 버튼
|
||||
|
||||
### 글수정 (edit.blade.php)
|
||||
- 기존 첨부파일 목록 (삭제 버튼 포함)
|
||||
- AJAX 파일 삭제 (확인 후 즉시 반영)
|
||||
- 새 파일 추가 영역
|
||||
- **드래그앤드롭 JS 이벤트** (dragenter, dragover, dragleave, drop)
|
||||
- **시각적 피드백** (드래그 시 테두리 파란색 변경)
|
||||
- **기존 파일 개수 고려** (최대 파일 수 체크 시 기존 파일 포함)
|
||||
|
||||
## 검증 결과
|
||||
|
||||
- [x] PHP 문법 검증 통과
|
||||
- [x] 라우트 등록 확인
|
||||
- [x] tenant 디스크 설정 확인
|
||||
- [x] Pint 코드 포맷팅 완료
|
||||
|
||||
## 다음 단계 (커밋)
|
||||
|
||||
### API 저장소
|
||||
```bash
|
||||
cd /Users/kent/Works/@KD_SAM/SAM/api
|
||||
git add .
|
||||
git commit -m "feat(SAM-API): board_files 테이블 삭제 마이그레이션"
|
||||
```
|
||||
|
||||
### MNG 저장소
|
||||
```bash
|
||||
cd /Users/kent/Works/@KD_SAM/SAM/mng
|
||||
git add .
|
||||
git commit -m "feat(SAM-MNG): 게시글 파일 첨부 기능 구현"
|
||||
```
|
||||
325
guides/ai-config-설정.md
Normal file
325
guides/ai-config-설정.md
Normal file
@@ -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/
|
||||
```
|
||||
262
guides/archive-restore-feature-analysis.md
Normal file
262
guides/archive-restore-feature-analysis.md
Normal file
@@ -0,0 +1,262 @@
|
||||
# Archive & Restore Feature Analysis
|
||||
|
||||
**날짜:** 2025-11-30
|
||||
**작업자:** Claude Code
|
||||
**요청:** 영구 삭제 데이터 복원 기능 구현
|
||||
|
||||
---
|
||||
|
||||
## 1. 요청 내용
|
||||
|
||||
- `https://mng.sam.kr/archived-records` 에서 영구 삭제된 데이터를 복원할 수 있는 기능
|
||||
- 삭제/복구 프로세스 정립:
|
||||
- 일반 관리자/테넌트: Soft Delete
|
||||
- 슈퍼관리자: 영구 삭제 가능 → archived_records에 저장
|
||||
- 영구 삭제 데이터: 복원 가능해야 함
|
||||
- UI 개선: 작업 설명 컬럼 개선
|
||||
|
||||
---
|
||||
|
||||
## 2. 현재 상태 분석
|
||||
|
||||
### 2.1 forceDelete 사용 서비스 (8개)
|
||||
|
||||
모든 서비스가 **아카이브 없이** 바로 영구 삭제:
|
||||
|
||||
| 서비스 | 메서드 | 삭제 대상 | 파일 위치 |
|
||||
|--------|--------|----------|-----------|
|
||||
| `TenantService` | `forceDeleteTenant()` | 테넌트 + 부서/메뉴/역할 | `app/Services/TenantService.php:115` |
|
||||
| `UserService` | `forceDeleteUser()` | 사용자 | `app/Services/UserService.php:232` |
|
||||
| `DepartmentService` | `forceDeleteDepartment()` | 부서 | `app/Services/DepartmentService.php:171` |
|
||||
| `MenuService` | `forceDeleteMenu()` | 메뉴 | `app/Services/MenuService.php:281` |
|
||||
| `BoardService` | `forceDeleteBoard()` | 게시판 | `app/Services/BoardService.php:141` |
|
||||
| `ProjectService` | `forceDeleteProject()` | 프로젝트 | `app/Services/ProjectManagement/ProjectService.php:134` |
|
||||
| `IssueService` | `forceDeleteIssue()` | 이슈 | `app/Services/ProjectManagement/IssueService.php:160` |
|
||||
| `TaskService` | `forceDeleteTask()` | 작업 | `app/Services/ProjectManagement/TaskService.php:168` |
|
||||
|
||||
### 2.2 현재 DB 스키마
|
||||
|
||||
**archived_records 테이블:**
|
||||
```
|
||||
id bigint PK
|
||||
batch_id char(36) -- UUID, 그룹핑용
|
||||
batch_description varchar(255) -- 배치 설명
|
||||
record_type varchar(50) -- ✅ varchar로 변경됨 (기존 enum)
|
||||
original_id bigint -- 원본 레코드 ID
|
||||
main_data json -- 원본 데이터 (JSON)
|
||||
schema_version varchar(50) -- 스키마 버전
|
||||
deleted_by bigint FK -- 삭제자
|
||||
deleted_at timestamp -- 삭제 시간
|
||||
notes text -- 메모
|
||||
created_at, updated_at, created_by, updated_by
|
||||
```
|
||||
|
||||
**archived_record_relations 테이블:**
|
||||
```
|
||||
id bigint PK
|
||||
archived_record_id bigint FK -- archived_records.id
|
||||
table_name varchar(100) -- 관련 테이블명
|
||||
data json -- 관련 데이터 (JSON)
|
||||
record_count int -- 레코드 수
|
||||
created_at, updated_at, created_by, updated_by
|
||||
```
|
||||
|
||||
### 2.3 문제점
|
||||
|
||||
1. **아카이브 생성 코드 없음**: `ArchivedRecord::create()` 호출하는 곳이 없음
|
||||
2. ✅ ~~**record_type enum 제한**~~: varchar로 변경 완료
|
||||
3. **복원 기능 없음**: RestoreService 미존재
|
||||
4. **데이터 유실**: forceDelete 시 데이터가 완전히 삭제됨
|
||||
|
||||
---
|
||||
|
||||
## 3. 구현 계획
|
||||
|
||||
### Phase 1: 인프라 구축 (이번 작업)
|
||||
|
||||
#### 3.1 마이그레이션 ✅ 완료
|
||||
- `record_type` enum → varchar(50) 변경 완료
|
||||
|
||||
#### 3.2 ArchiveService 생성
|
||||
```php
|
||||
class ArchiveService {
|
||||
// 단일 모델 아카이브
|
||||
public function archiveModel(Model $model, array $relations = [], ?string $batchId = null): ArchivedRecord
|
||||
|
||||
// 배치 아카이브 (여러 모델)
|
||||
public function archiveBatch(Collection $models, string $description, array $relations = []): string
|
||||
|
||||
// 모델별 record_type 매핑
|
||||
private function getRecordType(Model $model): string
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.3 RestoreService 생성
|
||||
```php
|
||||
class RestoreService {
|
||||
// 단일 레코드 복원
|
||||
public function restoreRecord(ArchivedRecord $record): Model
|
||||
|
||||
// 배치 전체 복원
|
||||
public function restoreBatch(string $batchId): Collection
|
||||
|
||||
// 관계 데이터 복원
|
||||
private function restoreRelations(ArchivedRecord $record): void
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.4 기존 서비스 수정 (TenantService, UserService 먼저)
|
||||
|
||||
#### 3.5 UI 개선
|
||||
- 복원 버튼 추가
|
||||
- 라우트 추가
|
||||
- 컨트롤러 메서드 추가
|
||||
|
||||
---
|
||||
|
||||
## 4. 수정 대상 파일
|
||||
|
||||
| # | 파일 | 작업 | 상태 |
|
||||
|---|------|------|------|
|
||||
| 1 | `database/migrations/2025_11_30_*_modify_archived_records_record_type_to_varchar.php` | 신규 | ✅ 완료 |
|
||||
| 2 | `app/Services/ArchiveService.php` | 신규 | 🔄 진행 중 |
|
||||
| 3 | `app/Services/RestoreService.php` | 신규 | ⏳ 대기 |
|
||||
| 4 | `app/Services/TenantService.php` | 수정 | ⏳ 대기 |
|
||||
| 5 | `app/Services/UserService.php` | 수정 | ⏳ 대기 |
|
||||
| 6 | `app/Http/Controllers/ArchivedRecordController.php` | 수정 | ⏳ 대기 |
|
||||
| 7 | `routes/web.php` | 수정 | ⏳ 대기 |
|
||||
| 8 | `resources/views/archived-records/show.blade.php` | 수정 | ⏳ 대기 |
|
||||
|
||||
---
|
||||
|
||||
## 5. record_type 매핑
|
||||
|
||||
```php
|
||||
$recordTypeMap = [
|
||||
Tenant::class => 'tenant',
|
||||
User::class => 'user',
|
||||
Department::class => 'department',
|
||||
Menu::class => 'menu',
|
||||
Role::class => 'role',
|
||||
Board::class => 'board',
|
||||
Project::class => 'project',
|
||||
Issue::class => 'issue',
|
||||
Task::class => 'task',
|
||||
];
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 복원 로직 흐름
|
||||
|
||||
```
|
||||
1. ArchivedRecord 조회 (batch_id 또는 id)
|
||||
2. main_data에서 원본 데이터 추출
|
||||
3. 원본 테이블에 INSERT (새 ID 할당)
|
||||
4. relations 복원 (ArchivedRecordRelation)
|
||||
5. ArchivedRecord 삭제
|
||||
6. 트랜잭션 커밋
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 주의 사항
|
||||
|
||||
- **FK 제약**: 복원 시 관계 테이블 순서 중요 (부모 먼저)
|
||||
- **ID 할당**: 복원 시 새 ID 할당 (original_id는 참조용)
|
||||
- **tenant_id 무결성**: Multi-tenant 데이터 복원 시 tenant_id 검증
|
||||
- **트랜잭션**: 복원 실패 시 롤백 필수
|
||||
|
||||
---
|
||||
|
||||
## 8. Phase 2: 테넌트 필터링 기능 추가 (신규 요청)
|
||||
|
||||
### 8.1 요청 내용
|
||||
|
||||
1. **대상 테넌트 필드 추가**:
|
||||
- 테넌트 삭제 시: 어떤 테넌트인지 표시
|
||||
- 사용자 삭제 시: 어떤 테넌트 소속인지 표시
|
||||
2. **상단 테넌트 선택 필터링**: 현재 선택된 테넌트의 아카이브만 표시
|
||||
|
||||
### 8.2 현재 문제점
|
||||
|
||||
- `archived_records` 테이블에 `tenant_id` 컬럼 없음
|
||||
- 사용자 삭제 시 소속 테넌트 정보 저장 안 됨
|
||||
- 테넌트 선택 필터링 불가
|
||||
|
||||
### 8.3 해결 방안
|
||||
|
||||
#### 방안 A: tenant_id 컬럼 추가 (권장)
|
||||
```
|
||||
장점:
|
||||
- 직접 필터링 가능 (성능 좋음)
|
||||
- 명확한 테넌트 소속 관계
|
||||
- 인덱스 활용 가능
|
||||
|
||||
단점:
|
||||
- 마이그레이션 필요
|
||||
- 기존 데이터 처리 필요 (main_data에서 추출)
|
||||
```
|
||||
|
||||
#### 방안 B: main_data에서 JSON 추출 (현재 방식)
|
||||
```
|
||||
장점:
|
||||
- DB 스키마 변경 없음
|
||||
|
||||
단점:
|
||||
- JSON 추출 쿼리 복잡
|
||||
- 성능 저하 (인덱스 불가)
|
||||
- 사용자의 경우 tenant_id가 main_data에 없을 수 있음
|
||||
```
|
||||
|
||||
### 8.4 권장 방안: A (tenant_id 컬럼 추가)
|
||||
|
||||
#### 수정 대상 파일
|
||||
|
||||
| # | 저장소 | 파일 | 작업 |
|
||||
|---|--------|------|------|
|
||||
| 1 | **api/** | `database/migrations/2025_12_01_*_add_tenant_id_to_archived_records.php` | 신규 - DB 마이그레이션 |
|
||||
| 2 | mng/ | `app/Services/ArchiveService.php` | 수정 - tenant_id 저장 로직 |
|
||||
| 3 | mng/ | `app/Services/ArchivedRecordService.php` | 수정 - 테넌트 필터링 |
|
||||
| 4 | mng/ | `app/Models/Archives/ArchivedRecord.php` | 수정 - fillable, 관계 추가 |
|
||||
| 5 | mng/ | `resources/views/archived-records/partials/table.blade.php` | 수정 - 대상 테넌트 표시 |
|
||||
|
||||
> **NOTE**: DB 마이그레이션은 `api/` 저장소에서 관리됨. mng/에서는 모델과 서비스만 수정.
|
||||
|
||||
#### 마이그레이션 내용 (api/)
|
||||
```php
|
||||
// api/database/migrations/2025_12_01_*_add_tenant_id_to_archived_records.php
|
||||
Schema::table('archived_records', function (Blueprint $table) {
|
||||
$table->unsignedBigInteger('tenant_id')->nullable()->after('record_type');
|
||||
$table->foreign('tenant_id')->references('id')->on('tenants')->nullOnDelete();
|
||||
$table->index('tenant_id');
|
||||
});
|
||||
```
|
||||
|
||||
#### tenant_id 결정 로직
|
||||
```
|
||||
- 테넌트 삭제: tenant_id = 삭제되는 테넌트의 ID (자기 자신)
|
||||
- 사용자 삭제: tenant_id = session('selected_tenant_id') (현재 선택된 테넌트)
|
||||
- 부서/메뉴/역할 삭제: tenant_id = 해당 레코드의 tenant_id
|
||||
```
|
||||
|
||||
#### 기존 데이터 처리
|
||||
```sql
|
||||
-- 테넌트 타입: main_data에서 id 추출
|
||||
UPDATE archived_records
|
||||
SET tenant_id = JSON_UNQUOTE(JSON_EXTRACT(main_data, '$.id'))
|
||||
WHERE record_type = 'tenant' AND tenant_id IS NULL;
|
||||
|
||||
-- 사용자 타입: main_data에 tenant_id가 없으므로 NULL 유지
|
||||
-- (또는 user_tenants 관계에서 추출 - 복잡)
|
||||
```
|
||||
|
||||
### 8.5 UI 변경
|
||||
|
||||
#### 목록 테이블 컬럼
|
||||
| 작업 설명 | 대상 테넌트 | 대상 정보 | 레코드 타입 | ... |
|
||||
|
||||
#### 필터링
|
||||
- 상단 테넌트 선택 시 `session('selected_tenant_id')` 기준 필터링
|
||||
- 슈퍼관리자: 전체 보기 가능
|
||||
- 일반 관리자: 소속 테넌트만 보기
|
||||
144
guides/barobill-members-migration.md
Normal file
144
guides/barobill-members-migration.md
Normal file
@@ -0,0 +1,144 @@
|
||||
# 바로빌 회원사관리 - 레거시 마이그레이션 계획
|
||||
|
||||
> 레거시 소스: `sam/sales/barobill/registration/index.php`
|
||||
|
||||
## 1. 레거시 분석
|
||||
|
||||
### 기술 스택
|
||||
- Frontend: React 18 + Babel (브라우저 트랜스파일링)
|
||||
- Backend: PHP + PDO (api.php)
|
||||
- UI: Tailwind CSS + Lucide Icons
|
||||
|
||||
### 데이터베이스 구조 (`barobill_members` 테이블)
|
||||
|
||||
| 필드명 | 타입 | 설명 |
|
||||
|--------|------|------|
|
||||
| `id` | INT | PK, Auto Increment |
|
||||
| `biz_no` | VARCHAR | 사업자번호 (Unique) |
|
||||
| `corp_name` | VARCHAR | 상호명 |
|
||||
| `ceo_name` | VARCHAR | 대표자명 |
|
||||
| `addr` | VARCHAR | 주소 |
|
||||
| `biz_type` | VARCHAR | 업태 |
|
||||
| `biz_class` | VARCHAR | 종목 |
|
||||
| `barobill_id` | VARCHAR | 바로빌 아이디 |
|
||||
| `barobill_pwd` | VARCHAR | 바로빌 비밀번호 (해시) |
|
||||
| `manager_name` | VARCHAR | 담당자명 |
|
||||
| `manager_email` | VARCHAR | 담당자 이메일 |
|
||||
| `manager_hp` | VARCHAR | 담당자 전화번호 |
|
||||
| `created_at` | TIMESTAMP | 생성일시 |
|
||||
|
||||
### API 엔드포인트 (레거시)
|
||||
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| GET | `api.php` | 전체 목록 조회 |
|
||||
| GET | `api.php?id={id}` | 단일 조회 |
|
||||
| POST | `api.php` | 신규 등록 (사업자번호 중복 체크) |
|
||||
| PUT | `api.php` | 정보 수정 |
|
||||
| DELETE | `api.php?id={id}` | 삭제 |
|
||||
|
||||
### UI 기능
|
||||
|
||||
1. **통계 카드 (4개)**
|
||||
- 연동 회원사 수 (DB 실시간)
|
||||
- API 키 상태
|
||||
- 트래픽 상태
|
||||
- 서버 상태
|
||||
|
||||
2. **탭 네비게이션**
|
||||
- 목록 조회
|
||||
- 신규 등록
|
||||
|
||||
3. **목록 테이블 컬럼**
|
||||
- 사업자번호
|
||||
- 상호 / 대표자
|
||||
- 바로빌 ID
|
||||
- 담당자 정보
|
||||
- 작업 (수정/삭제)
|
||||
|
||||
4. **등록 폼 필드**
|
||||
- 사업자번호 (필수)
|
||||
- 상호명 (필수)
|
||||
- 대표자명 (필수)
|
||||
- 업태
|
||||
- 종목
|
||||
- 주소
|
||||
- 바로빌 아이디 (필수, 등록 시만)
|
||||
- 비밀번호 (필수, 등록 시만)
|
||||
- 담당자명
|
||||
- 담당자 HP
|
||||
- 담당자 이메일
|
||||
- **자동완성 버튼** (테스트 데이터 입력)
|
||||
|
||||
5. **수정 모달**
|
||||
- 등록 폼과 동일 (아이디/비밀번호 제외)
|
||||
|
||||
---
|
||||
|
||||
## 2. Laravel 마이그레이션 계획
|
||||
|
||||
### 생성할 파일 목록
|
||||
|
||||
#### Model & Migration
|
||||
```
|
||||
app/Models/BarobillMember.php
|
||||
database/migrations/xxxx_create_barobill_members_table.php
|
||||
```
|
||||
|
||||
#### Controller
|
||||
```
|
||||
app/Http/Controllers/Barobill/BarobillController.php (이미 생성됨)
|
||||
app/Http/Controllers/Api/Admin/BarobillController.php (API용)
|
||||
```
|
||||
|
||||
#### Views
|
||||
```
|
||||
resources/views/barobill/members/index.blade.php (이미 생성됨 - 업데이트 필요)
|
||||
resources/views/barobill/members/partials/table.blade.php
|
||||
resources/views/barobill/members/partials/form.blade.php
|
||||
resources/views/barobill/members/partials/modal-edit.blade.php
|
||||
```
|
||||
|
||||
#### Routes
|
||||
```php
|
||||
// Web Routes (이미 추가됨)
|
||||
Route::prefix('barobill')->name('barobill.')->group(function () {
|
||||
Route::get('/members', [BarobillController::class, 'members'])->name('members.index');
|
||||
});
|
||||
|
||||
// API Routes (추가 필요)
|
||||
Route::prefix('barobill')->name('barobill.')->group(function () {
|
||||
Route::get('/members', [BarobillApiController::class, 'index']);
|
||||
Route::get('/members/{id}', [BarobillApiController::class, 'show']);
|
||||
Route::post('/members', [BarobillApiController::class, 'store']);
|
||||
Route::put('/members/{id}', [BarobillApiController::class, 'update']);
|
||||
Route::delete('/members/{id}', [BarobillApiController::class, 'destroy']);
|
||||
});
|
||||
```
|
||||
|
||||
### 구현 순서
|
||||
|
||||
1. [ ] Migration 생성 및 실행
|
||||
2. [ ] Model 생성 (fillable, casts 설정)
|
||||
3. [ ] API Controller 생성 (CRUD)
|
||||
4. [ ] API Routes 추가
|
||||
5. [ ] View 업데이트 (HTMX + Blade)
|
||||
- 통계 카드
|
||||
- 탭 (목록/등록)
|
||||
- 테이블 (HTMX 로드)
|
||||
- 등록 폼
|
||||
- 수정 모달
|
||||
6. [ ] 테스트
|
||||
|
||||
---
|
||||
|
||||
## 3. 참고 사항
|
||||
|
||||
### 레거시 코드 위치
|
||||
- Frontend: `sam/sales/barobill/registration/index.php`
|
||||
- Backend API: `sam/sales/barobill/registration/api.php`
|
||||
|
||||
### 주의 사항
|
||||
- 사업자번호 중복 체크 로직 필요
|
||||
- 비밀번호는 해시 저장 (password_hash)
|
||||
- 바로빌 API 연동은 별도 Service 클래스로 분리 권장
|
||||
174
guides/super-admin-protection.md
Normal file
174
guides/super-admin-protection.md
Normal file
@@ -0,0 +1,174 @@
|
||||
# Super Admin Protection Feature
|
||||
|
||||
**날짜:** 2025-12-01
|
||||
**작업자:** Claude Code
|
||||
**요청:** 슈퍼관리자 보호 및 복원/영구삭제 권한 분리
|
||||
|
||||
---
|
||||
|
||||
## 1. 요구사항
|
||||
|
||||
### 1.1 슈퍼관리자 보호
|
||||
- 일반관리자는 슈퍼관리자를 **볼 수 없음** (목록에서 숨김)
|
||||
- 일반관리자는 슈퍼관리자를 **수정/삭제할 수 없음**
|
||||
- 슈퍼관리자만 다른 슈퍼관리자를 관리 가능
|
||||
|
||||
### 1.2 복원/영구삭제 권한 분리
|
||||
- **복원 (Restore)**: 일반관리자도 가능
|
||||
- **영구삭제 (Force Delete)**: 슈퍼관리자 전용
|
||||
|
||||
---
|
||||
|
||||
## 2. 구현 내용
|
||||
|
||||
### 2.1 라우트 수정 (`routes/api.php`)
|
||||
|
||||
8개 엔티티의 restore 라우트를 `super.admin` 미들웨어 밖으로 이동:
|
||||
|
||||
| 엔티티 | 라인 | 복원 라우트 | 영구삭제 라우트 |
|
||||
|--------|------|-------------|-----------------|
|
||||
| Tenants | 42-48 | `POST /{id}/restore` | `DELETE /{id}/force` (super.admin) |
|
||||
| Departments | 76-82 | `POST /{id}/restore` | `DELETE /{id}/force` (super.admin) |
|
||||
| Users | 93-99 | `POST /{id}/restore` | `DELETE /{id}/force` (super.admin) |
|
||||
| Menus | 117-123 | `POST /{id}/restore` | `DELETE /{id}/force` (super.admin) |
|
||||
| Boards | 151-157 | `POST /{id}/restore` | `DELETE /{id}/force` (super.admin) |
|
||||
| PM Projects | 234-240 | `POST /{id}/restore` | `DELETE /{id}/force` (super.admin) |
|
||||
| PM Tasks | 260-266 | `POST /{id}/restore` | `DELETE /{id}/force` (super.admin) |
|
||||
| PM Issues | 292-298 | `POST /{id}/restore` | `DELETE /{id}/force` (super.admin) |
|
||||
|
||||
**패턴:**
|
||||
```php
|
||||
// 복원 (일반관리자 가능)
|
||||
Route::post('/{id}/restore', [Controller::class, 'restore'])->name('restore');
|
||||
|
||||
// 슈퍼관리자 전용 액션 (영구삭제)
|
||||
Route::middleware('super.admin')->group(function () {
|
||||
Route::delete('/{id}/force', [Controller::class, 'forceDestroy'])->name('forceDestroy');
|
||||
});
|
||||
```
|
||||
|
||||
### 2.2 서비스 레이어 수정
|
||||
|
||||
#### `app/Services/UserService.php`
|
||||
```php
|
||||
public function canAccessUser(int $targetUserId): bool
|
||||
{
|
||||
// withTrashed()를 사용하여 soft-deleted 사용자도 확인 (복원 시 필요)
|
||||
$targetUser = User::withTrashed()->find($targetUserId);
|
||||
$currentUser = auth()->user();
|
||||
|
||||
// 대상 사용자가 슈퍼관리자이고 현재 사용자가 슈퍼관리자가 아니면 접근 불가
|
||||
if ($targetUser?->is_super_admin && ! $currentUser?->is_super_admin) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
```
|
||||
|
||||
#### `app/Services/UserPermissionService.php`
|
||||
```php
|
||||
public function canModifyUser(int $targetUserId): bool
|
||||
{
|
||||
// withTrashed()를 사용하여 일관성 유지
|
||||
$targetUser = User::withTrashed()->find($targetUserId);
|
||||
$currentUser = auth()->user();
|
||||
|
||||
if ($targetUser?->is_super_admin && ! $currentUser?->is_super_admin) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
```
|
||||
|
||||
**핵심 수정**: `User::find()` → `User::withTrashed()->find()`
|
||||
- Soft-deleted 사용자도 조회 가능하게 변경
|
||||
- 복원 작업 시 권한 체크가 정상 작동
|
||||
|
||||
### 2.3 뷰 레이어 수정
|
||||
|
||||
6개 테이블 뷰에 권한별 버튼 표시 로직 적용:
|
||||
|
||||
| 파일 | 복원 버튼 | 영구삭제 버튼 |
|
||||
|------|-----------|---------------|
|
||||
| `users/partials/table.blade.php` | `$canModify` 체크 | `is_super_admin` 체크 |
|
||||
| `users/partials/modal-info.blade.php` | 슈퍼관리자이거나 대상이 일반사용자 | - |
|
||||
| `departments/partials/table.blade.php` | 항상 표시 | `is_super_admin` 체크 |
|
||||
| `menus/partials/table.blade.php` | 항상 표시 | `is_super_admin` 체크 |
|
||||
| `boards/partials/table.blade.php` | 항상 표시 | `is_super_admin` 체크 |
|
||||
| `tenants/partials/table.blade.php` | 항상 표시 | `is_super_admin` 체크 |
|
||||
| `project-management/projects/partials/table.blade.php` | 항상 표시 | `is_super_admin` 체크 |
|
||||
|
||||
**Blade 패턴:**
|
||||
```blade
|
||||
@if($item->deleted_at)
|
||||
<!-- 삭제된 항목 - 복원은 일반관리자도 가능, 영구삭제는 슈퍼관리자만 -->
|
||||
<button onclick="confirmRestore({{ $item->id }}, '{{ $item->name }}')">
|
||||
복원
|
||||
</button>
|
||||
@if(auth()->user()?->is_super_admin)
|
||||
<button onclick="confirmForceDelete({{ $item->id }}, '{{ $item->name }}')">
|
||||
영구삭제
|
||||
</button>
|
||||
@endif
|
||||
@endif
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 수정된 파일 목록
|
||||
|
||||
### 라우트
|
||||
- `routes/api.php` - 8개 엔티티 restore 라우트 분리
|
||||
|
||||
### 서비스
|
||||
- `app/Services/UserService.php` - `canAccessUser()` withTrashed 적용
|
||||
- `app/Services/UserPermissionService.php` - `canModifyUser()` withTrashed 적용
|
||||
|
||||
### 뷰 (Blade)
|
||||
- `resources/views/users/partials/table.blade.php`
|
||||
- `resources/views/users/partials/modal-info.blade.php`
|
||||
- `resources/views/departments/partials/table.blade.php`
|
||||
- `resources/views/menus/partials/table.blade.php`
|
||||
- `resources/views/boards/partials/table.blade.php`
|
||||
- `resources/views/tenants/partials/table.blade.php`
|
||||
- `resources/views/project-management/projects/partials/table.blade.php`
|
||||
|
||||
---
|
||||
|
||||
## 4. 테스트 시나리오
|
||||
|
||||
### 4.1 일반관리자 테스트
|
||||
- [ ] 사용자 목록에서 슈퍼관리자가 보이지 않음
|
||||
- [ ] 삭제된 사용자 복원 가능
|
||||
- [ ] 삭제된 부서/메뉴/게시판/테넌트/프로젝트 복원 가능
|
||||
- [ ] 영구삭제 버튼이 보이지 않음
|
||||
- [ ] 슈퍼관리자 수정/삭제 불가 (API 레벨)
|
||||
|
||||
### 4.2 슈퍼관리자 테스트
|
||||
- [ ] 모든 사용자 조회 가능 (슈퍼관리자 포함)
|
||||
- [ ] 삭제된 항목 복원 가능
|
||||
- [ ] 영구삭제 가능
|
||||
- [ ] 다른 슈퍼관리자 관리 가능
|
||||
|
||||
---
|
||||
|
||||
## 5. 이슈 해결
|
||||
|
||||
### 5.1 302 Found 에러
|
||||
**문제**: 일반관리자가 복원 API 호출 시 302 리다이렉트 발생
|
||||
**원인**: restore 라우트가 `super.admin` 미들웨어 내부에 있었음
|
||||
**해결**: restore 라우트를 미들웨어 밖으로 이동
|
||||
|
||||
### 5.2 Soft-deleted 사용자 권한 체크 실패
|
||||
**문제**: `User::find()`가 soft-deleted 사용자를 조회하지 못함
|
||||
**원인**: Eloquent 기본 동작으로 soft-deleted 레코드 제외
|
||||
**해결**: `User::withTrashed()->find()` 사용
|
||||
|
||||
---
|
||||
|
||||
## 6. 관련 문서
|
||||
|
||||
- `claudedocs/archive-restore-feature-analysis.md` - 아카이브/복원 기능 분석
|
||||
- `CURRENT_WORKS.md` - 작업 히스토리
|
||||
367
guides/명함추출로직.md
Normal file
367
guides/명함추출로직.md
Normal file
@@ -0,0 +1,367 @@
|
||||
# 명함 OCR 추출 로직 기술 문서
|
||||
|
||||
## 개요
|
||||
|
||||
명함 이미지를 업로드하면 Google Gemini Vision API를 통해 자동으로 정보를 추출하여 영업권 등록 폼에 자동 입력하는 시스템입니다.
|
||||
|
||||
---
|
||||
|
||||
## 시스템 아키텍처
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ 클라이언트 │ │ MNG 서버 │ │ Gemini API │
|
||||
│ (Blade View) │ │ (Laravel) │ │ (Google) │
|
||||
└────────┬────────┘ └────────┬────────┘ └────────┬────────┘
|
||||
│ │ │
|
||||
│ 1. 이미지 업로드 │ │
|
||||
│ (Base64) │ │
|
||||
├──────────────────────>│ │
|
||||
│ │ 2. Vision API 호출 │
|
||||
│ ├──────────────────────>│
|
||||
│ │ │
|
||||
│ │ 3. JSON 응답 │
|
||||
│ │<──────────────────────┤
|
||||
│ 4. 추출 데이터 반환 │ │
|
||||
│<──────────────────────┤ │
|
||||
│ │ │
|
||||
│ 5. 폼 필드 자동 입력 │ │
|
||||
│ │ │
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 파일 구조
|
||||
|
||||
```
|
||||
/home/aweso/sam/mng/
|
||||
├── app/
|
||||
│ ├── Http/Controllers/
|
||||
│ │ ├── Api/
|
||||
│ │ │ └── BusinessCardOcrController.php # OCR API 엔드포인트
|
||||
│ │ └── System/
|
||||
│ │ └── AiConfigController.php # AI 설정 관리
|
||||
│ ├── Models/System/
|
||||
│ │ └── AiConfig.php # AI API 설정 모델
|
||||
│ └── Services/
|
||||
│ └── BusinessCardOcrService.php # Gemini Vision API 호출 서비스
|
||||
├── resources/views/
|
||||
│ ├── sales/prospects/
|
||||
│ │ └── create.blade.php # 영업권 등록 (드래그앤드롭 UI)
|
||||
│ └── system/ai-config/
|
||||
│ └── index.blade.php # AI 설정 관리 페이지
|
||||
└── routes/
|
||||
└── web.php # 라우트 정의
|
||||
|
||||
/home/aweso/sam/api/
|
||||
└── database/migrations/
|
||||
└── 2026_01_27_100000_create_ai_configs_table.php # AI 설정 테이블
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 데이터베이스 스키마
|
||||
|
||||
### ai_configs 테이블
|
||||
|
||||
| 컬럼 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| id | BIGINT | PK |
|
||||
| name | VARCHAR(50) | 설정 이름 |
|
||||
| provider | VARCHAR(30) | 제공자 (gemini, claude, openai) |
|
||||
| api_key | VARCHAR(255) | API 키 (암호화 저장 권장) |
|
||||
| model | VARCHAR(100) | 모델명 (예: gemini-2.0-flash) |
|
||||
| base_url | VARCHAR(255) | API Base URL (NULL이면 기본값 사용) |
|
||||
| description | TEXT | 설명 |
|
||||
| is_active | BOOLEAN | 활성화 여부 (provider당 1개만 활성) |
|
||||
| options | JSON | 추가 옵션 |
|
||||
| created_at | TIMESTAMP | 생성일시 |
|
||||
| updated_at | TIMESTAMP | 수정일시 |
|
||||
| deleted_at | TIMESTAMP | 삭제일시 (소프트삭제) |
|
||||
|
||||
---
|
||||
|
||||
## API 엔드포인트
|
||||
|
||||
### POST /api/business-card-ocr
|
||||
|
||||
명함 이미지에서 정보를 추출합니다.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"image": "data:image/jpeg;base64,/9j/4AAQSkZJRg..."
|
||||
}
|
||||
```
|
||||
|
||||
**Response (성공):**
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"company_name": "주식회사 샘플",
|
||||
"ceo_name": "홍길동",
|
||||
"business_number": "123-45-67890",
|
||||
"contact_phone": "02-1234-5678",
|
||||
"contact_email": "hong@sample.com",
|
||||
"address": "서울시 강남구 테헤란로 123",
|
||||
"position": "대표이사",
|
||||
"department": "경영지원팀"
|
||||
},
|
||||
"raw_response": "{...}"
|
||||
}
|
||||
```
|
||||
|
||||
**Response (실패):**
|
||||
```json
|
||||
{
|
||||
"ok": false,
|
||||
"error": "Gemini API 설정이 없습니다."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 핵심 로직
|
||||
|
||||
### 1. BusinessCardOcrService.php
|
||||
|
||||
```php
|
||||
class BusinessCardOcrService
|
||||
{
|
||||
public function extractFromImage(string $base64Image): array
|
||||
{
|
||||
// 1. 활성화된 Gemini 설정 조회
|
||||
$config = AiConfig::getActiveGemini();
|
||||
|
||||
// 2. Gemini Vision API 호출
|
||||
return $this->callGeminiVisionApi($config, $base64Image);
|
||||
}
|
||||
|
||||
private function callGeminiVisionApi(AiConfig $config, string $base64Image): array
|
||||
{
|
||||
// API URL 구성
|
||||
$url = "{$config->base_url}/models/{$config->model}:generateContent?key={$config->api_key}";
|
||||
|
||||
// Base64 이미지 데이터 처리
|
||||
// data:image/jpeg;base64, 접두사 제거
|
||||
|
||||
// API 호출
|
||||
$response = Http::timeout(30)->post($url, [
|
||||
'contents' => [[
|
||||
'parts' => [
|
||||
['inline_data' => ['mime_type' => $mimeType, 'data' => $imageData]],
|
||||
['text' => $prompt]
|
||||
]
|
||||
]],
|
||||
'generationConfig' => [
|
||||
'temperature' => 0.1,
|
||||
'responseMimeType' => 'application/json'
|
||||
]
|
||||
]);
|
||||
|
||||
// 응답 파싱 및 정규화
|
||||
return $this->normalizeData($parsed);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Gemini Vision API 프롬프트
|
||||
|
||||
```
|
||||
이 명함 이미지에서 다음 정보를 추출해주세요.
|
||||
|
||||
## 추출 항목
|
||||
1. company_name: 회사명/상호
|
||||
2. ceo_name: 대표자명/담당자명
|
||||
3. business_number: 사업자등록번호 (000-00-00000 형식)
|
||||
4. contact_phone: 연락처/전화번호
|
||||
5. contact_email: 이메일
|
||||
6. address: 주소
|
||||
7. position: 직책
|
||||
8. department: 부서
|
||||
|
||||
## 규칙
|
||||
1. 정보가 없으면 빈 문자열("")로 응답
|
||||
2. 사업자번호는 10자리 숫자를 000-00-00000 형식으로 변환
|
||||
3. 전화번호는 하이픈 포함 형식 유지
|
||||
4. 한국어로 된 정보를 우선 추출
|
||||
|
||||
## 출력 형식 (JSON)
|
||||
{
|
||||
"company_name": "",
|
||||
"ceo_name": "",
|
||||
"business_number": "",
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 데이터 정규화
|
||||
|
||||
```php
|
||||
private function normalizeData(array $data): array
|
||||
{
|
||||
// 사업자번호 정규화 (10자리 → 000-00-00000)
|
||||
if (!empty($data['business_number'])) {
|
||||
$digits = preg_replace('/\D/', '', $data['business_number']);
|
||||
if (strlen($digits) === 10) {
|
||||
$data['business_number'] = substr($digits, 0, 3) . '-'
|
||||
. substr($digits, 3, 2) . '-'
|
||||
. substr($digits, 5);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'company_name' => trim($data['company_name'] ?? ''),
|
||||
'ceo_name' => trim($data['ceo_name'] ?? ''),
|
||||
// ... 기타 필드
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 프론트엔드 (create.blade.php)
|
||||
|
||||
### 드래그앤드롭 영역
|
||||
|
||||
```html
|
||||
<div id="ocr-drop-zone" class="border-2 border-dashed border-gray-300 rounded-lg p-8">
|
||||
<p>명함 이미지를 드래그하거나 클릭하여 업로드</p>
|
||||
<input type="file" id="ocr-file-input" accept="image/*" class="hidden">
|
||||
</div>
|
||||
<div id="ocr-preview" class="hidden">
|
||||
<img id="ocr-preview-image" class="max-h-48 rounded-lg">
|
||||
</div>
|
||||
```
|
||||
|
||||
### JavaScript 처리 로직
|
||||
|
||||
```javascript
|
||||
// 파일 처리
|
||||
async function handleFile(file) {
|
||||
// 1. 이미지 미리보기
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (e) => {
|
||||
// 미리보기 표시
|
||||
document.getElementById('ocr-preview-image').src = e.target.result;
|
||||
|
||||
// 2. OCR API 호출
|
||||
showOcrLoading(true);
|
||||
const response = await fetch('/api/business-card-ocr', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken },
|
||||
body: JSON.stringify({ image: e.target.result })
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
// 3. 폼 필드 자동 입력
|
||||
if (result.ok) {
|
||||
fillFormFields(result.data);
|
||||
}
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
// 폼 필드 자동 입력 (하이라이트 효과 포함)
|
||||
function fillFormFields(data) {
|
||||
const fieldMap = {
|
||||
'company_name': 'name',
|
||||
'ceo_name': 'ceo_name',
|
||||
'business_number': 'business_number',
|
||||
// ...
|
||||
};
|
||||
|
||||
for (const [key, fieldName] of Object.entries(fieldMap)) {
|
||||
if (data[key]) {
|
||||
const input = document.querySelector(`[name="${fieldName}"]`);
|
||||
if (input) {
|
||||
input.value = data[key];
|
||||
// 하이라이트 효과
|
||||
input.classList.add('bg-yellow-100');
|
||||
setTimeout(() => input.classList.remove('bg-yellow-100'), 2000);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## AI 설정 관리
|
||||
|
||||
### 라우트
|
||||
|
||||
```php
|
||||
// routes/web.php
|
||||
Route::prefix('system')->name('system.')->group(function () {
|
||||
Route::get('ai-config', [AiConfigController::class, 'index'])->name('ai-config.index');
|
||||
Route::post('ai-config', [AiConfigController::class, 'store'])->name('ai-config.store');
|
||||
Route::put('ai-config/{id}', [AiConfigController::class, 'update'])->name('ai-config.update');
|
||||
Route::delete('ai-config/{id}', [AiConfigController::class, 'destroy'])->name('ai-config.destroy');
|
||||
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('api/business-card-ocr', [BusinessCardOcrController::class, 'process']);
|
||||
```
|
||||
|
||||
### Provider별 기본 설정
|
||||
|
||||
```php
|
||||
// AiConfig.php
|
||||
public const DEFAULT_BASE_URLS = [
|
||||
'gemini' => 'https://generativelanguage.googleapis.com/v1beta',
|
||||
'claude' => 'https://api.anthropic.com/v1',
|
||||
'openai' => 'https://api.openai.com/v1',
|
||||
];
|
||||
|
||||
public const DEFAULT_MODELS = [
|
||||
'gemini' => 'gemini-2.0-flash',
|
||||
'claude' => 'claude-sonnet-4-20250514',
|
||||
'openai' => 'gpt-4o',
|
||||
];
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 에러 처리
|
||||
|
||||
| 상황 | 에러 메시지 | 대응 |
|
||||
|------|------------|------|
|
||||
| Gemini 설정 없음 | "Gemini API 설정이 없습니다" | AI 설정 페이지에서 설정 추가 |
|
||||
| API 호출 실패 | "AI API 호출 실패: {status}" | API 키/모델 확인 |
|
||||
| 연결 실패 | "AI API 연결 실패" | 네트워크/Base URL 확인 |
|
||||
| 응답 파싱 실패 | "AI 응답 파싱 실패" | 프롬프트 조정 필요 |
|
||||
| Rate Limit | 429 에러 | 잠시 후 재시도 |
|
||||
|
||||
---
|
||||
|
||||
## 보안 고려사항
|
||||
|
||||
1. **API 키 보호**: `api_key` 컬럼 암호화 저장 권장
|
||||
2. **마스킹**: UI에서 API 키 앞 8자리만 표시
|
||||
3. **CSRF 보호**: 모든 POST 요청에 CSRF 토큰 포함
|
||||
4. **파일 검증**: 이미지 파일만 허용 (accept="image/*")
|
||||
|
||||
---
|
||||
|
||||
## 향후 개선 사항
|
||||
|
||||
1. **Claude/OpenAI Vision 지원**: 현재 Gemini만 지원, 타 provider 확장 가능
|
||||
2. **배치 처리**: 여러 명함 동시 처리
|
||||
3. **OCR 결과 캐싱**: 동일 이미지 재처리 방지
|
||||
4. **API 키 암호화**: Laravel Crypt 활용
|
||||
|
||||
---
|
||||
|
||||
## 참고 자료
|
||||
|
||||
- [Gemini API 문서](https://ai.google.dev/gemini-api/docs)
|
||||
- [Gemini Vision API](https://ai.google.dev/gemini-api/docs/vision)
|
||||
- API 키 파일 위치: `/home/aweso/sam/sales/apikey/gemini_api_key.txt`
|
||||
|
||||
---
|
||||
|
||||
*문서 작성일: 2026-01-27*
|
||||
233
guides/모달창_생성시_유의사항.md
Normal file
233
guides/모달창_생성시_유의사항.md
Normal file
@@ -0,0 +1,233 @@
|
||||
# 모달창 생성 시 유의사항
|
||||
|
||||
## 개요
|
||||
|
||||
이 문서는 SAM 프로젝트에서 모달창을 구현할 때 발생할 수 있는 문제점과 해결 방법을 정리한 것입니다.
|
||||
|
||||
---
|
||||
|
||||
## 1. pointer-events 문제
|
||||
|
||||
### 문제 상황
|
||||
|
||||
모달 배경 클릭을 방지하면서 모달 내부만 클릭 가능하게 하려고 다음과 같은 구조를 사용했을 때:
|
||||
|
||||
```html
|
||||
<!-- 문제가 발생하는 구조 -->
|
||||
<div class="fixed inset-0 z-50">
|
||||
<div class="absolute inset-0 bg-black bg-opacity-50"></div>
|
||||
<div class="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<div class="bg-white rounded-xl pointer-events-auto">
|
||||
<!-- AJAX로 로드되는 내용 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**증상**: 모달은 표시되지만 내부의 버튼, 입력 필드 등 모든 요소가 클릭되지 않음 (마치 돌덩어리처럼 동작)
|
||||
|
||||
### 원인
|
||||
|
||||
- `pointer-events-none`이 부모에 있고 `pointer-events-auto`가 자식에 있는 구조
|
||||
- AJAX로 로드된 내용이 `pointer-events-auto` div 안에 들어가도, 그 안의 요소들에 pointer-events가 제대로 상속되지 않을 수 있음
|
||||
- 특히 동적으로 로드된 HTML에서 이 문제가 자주 발생
|
||||
|
||||
### 해결 방법
|
||||
|
||||
`pointer-events-none/auto` 구조를 사용하지 않고 단순화:
|
||||
|
||||
```html
|
||||
<!-- 올바른 구조 -->
|
||||
<div id="modal" class="hidden fixed inset-0 z-50 overflow-y-auto">
|
||||
<!-- 배경 오버레이 -->
|
||||
<div class="fixed inset-0 bg-black bg-opacity-50"></div>
|
||||
<!-- 모달 컨텐츠 wrapper -->
|
||||
<div class="flex min-h-full items-center justify-center p-4">
|
||||
<div id="modalContent" class="relative bg-white rounded-xl shadow-2xl w-full max-w-3xl">
|
||||
<!-- 내용 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. AJAX로 로드된 HTML에서 함수 호출 문제
|
||||
|
||||
### 문제 상황
|
||||
|
||||
```html
|
||||
<!-- AJAX로 로드된 HTML -->
|
||||
<button onclick="closeModal()">닫기</button>
|
||||
```
|
||||
|
||||
**증상**: `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
|
||||
<!-- HTML: data 속성 사용 -->
|
||||
<button data-close-modal>닫기</button>
|
||||
```
|
||||
|
||||
```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
|
||||
<!-- 모달 -->
|
||||
<div id="exampleModal" class="hidden fixed inset-0 z-50 overflow-y-auto">
|
||||
<!-- 배경 오버레이 -->
|
||||
<div class="fixed inset-0 bg-black bg-opacity-50 transition-opacity"></div>
|
||||
<!-- 모달 컨텐츠 wrapper -->
|
||||
<div class="flex min-h-full items-center justify-center p-4">
|
||||
<div id="exampleModalContent" class="relative bg-white rounded-xl shadow-2xl w-full max-w-3xl">
|
||||
<!-- 로딩 표시 또는 내용 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 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
|
||||
<div class="p-6">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-xl font-bold">모달 제목</h2>
|
||||
<!-- data-close-modal 속성 사용 -->
|
||||
<button type="button" data-close-modal class="text-gray-400 hover:text-gray-600">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 내용 -->
|
||||
|
||||
<div class="flex justify-end gap-3 mt-6">
|
||||
<button type="button" data-close-modal class="px-4 py-2 border rounded-lg">취소</button>
|
||||
<button type="submit" class="px-4 py-2 bg-blue-600 text-white rounded-lg">확인</button>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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` - 수정 모달 부분 뷰
|
||||
443
guides/상품관리정보.md
Normal file
443
guides/상품관리정보.md
Normal file
@@ -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 |
|
||||
372
guides/수당지급.md
Normal file
372
guides/수당지급.md
Normal file
@@ -0,0 +1,372 @@
|
||||
# 수당 지급 시스템
|
||||
|
||||
> SAM 프로젝트 영업파트너 수당 지급 시스템 기술 문서
|
||||
>
|
||||
> 최종 수정: 2026-01-30
|
||||
|
||||
---
|
||||
|
||||
## 1. 개요
|
||||
|
||||
### 1.1 목적
|
||||
이 문서는 SAM 영업관리 시스템의 **수당 계산 및 지급 프로세스**를 정의합니다.
|
||||
|
||||
### 1.2 수당 유형
|
||||
|
||||
| 수당 유형 | 수당률/금액 | 대상 | 기준 |
|
||||
|-----------|-------------|------|------|
|
||||
| **판매자 수당** | 20% | 가망고객 등록자 | 가입비의 50% 기준 |
|
||||
| **매니저 수당** | 5% | 지정된 매니저 | 가입비의 50% 기준 |
|
||||
| **협업지원금** | 메뉴당 2,000원 | 2단계 상위 파트너 | 가입비 완납 시 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 수당 계산 로직
|
||||
|
||||
### 2.1 기본 공식
|
||||
|
||||
```
|
||||
기준 금액 = 총 가입비 ÷ 2 (50%)
|
||||
|
||||
판매자 수당 = 기준 금액 × 20%
|
||||
매니저 수당 = 기준 금액 × 5%
|
||||
```
|
||||
|
||||
### 2.2 계산 예시
|
||||
|
||||
```
|
||||
총 가입비: 10,000,000원
|
||||
기준 금액: 5,000,000원 (50%)
|
||||
|
||||
판매자 수당: 5,000,000 × 20% = 1,000,000원
|
||||
매니저 수당: 5,000,000 × 5% = 250,000원
|
||||
```
|
||||
|
||||
### 2.3 입금 구분별 수당
|
||||
|
||||
| 입금 구분 | 코드 | 설명 |
|
||||
|-----------|------|------|
|
||||
| **계약금** | `deposit` | 계약 시 선입금 |
|
||||
| **잔금** | `balance` | 계약 후 잔여금 |
|
||||
|
||||
각 입금 시점마다 별도의 수당이 생성됩니다.
|
||||
|
||||
---
|
||||
|
||||
## 3. 협업지원금
|
||||
|
||||
### 3.1 도입 배경
|
||||
|
||||
**다단계 판매법 준수**: 다단계 판매법에서는 2단계 이상의 수당 지급이 금지되어 있습니다.
|
||||
이를 준수하면서도 상위 파트너의 기여를 인정하기 위해 "수당"이 아닌 "지원금" 형태로 지급합니다.
|
||||
|
||||
### 3.2 지급 대상
|
||||
|
||||
계약 체결자(판매자) 기준 **2단계 상위 파트너** (할아버지 파트너)
|
||||
|
||||
```
|
||||
할아버지 파트너 ← 협업지원금 수령
|
||||
│
|
||||
↓ (유치)
|
||||
아버지 파트너
|
||||
│
|
||||
↓ (유치)
|
||||
손자 파트너 ← 테넌트 계약 체결 (판매자 수당 20%)
|
||||
│
|
||||
↓
|
||||
테넌트 계약
|
||||
```
|
||||
|
||||
### 3.3 산출 기준
|
||||
|
||||
| 항목 | 내용 |
|
||||
|------|------|
|
||||
| **산출 공식** | 테넌트 메뉴 개수 × 2,000원 |
|
||||
| **지급 시점** | 가입비 완납 시 |
|
||||
| **지급 대상** | 계약자의 parent의 parent (2단계 상위) |
|
||||
|
||||
### 3.4 계산 예시
|
||||
|
||||
```
|
||||
[상황]
|
||||
- 손자 파트너가 테넌트 A와 계약 체결
|
||||
- 테넌트 A에 메뉴 50개 생성
|
||||
- 가입비 1,000만원 완납
|
||||
|
||||
[수당/지원금 지급]
|
||||
손자 파트너 (판매자): 500만원 × 20% = 100만원
|
||||
매니저 (지정된 경우): 500만원 × 5% = 25만원
|
||||
할아버지 파트너: 50개 × 2,000원 = 10만원 (협업지원금)
|
||||
```
|
||||
|
||||
### 3.5 지급 조건
|
||||
|
||||
1. 계약자(손자)의 parent_id가 존재해야 함 (아버지 파트너)
|
||||
2. 아버지 파트너의 parent_id가 존재해야 함 (할아버지 파트너)
|
||||
3. 가입비가 **완납**되어야 함
|
||||
4. 테넌트에 메뉴가 생성되어 있어야 함
|
||||
|
||||
> **주의**: 1단계 상위(아버지)는 협업지원금 대상이 아님.
|
||||
> 직접 유치한 파트너의 계약에 대해서는 별도 수당 정책 없음 (다단계법 준수).
|
||||
|
||||
---
|
||||
|
||||
## 4. 수당 지급 프로세스
|
||||
|
||||
### 3.1 상태 흐름
|
||||
|
||||
```
|
||||
┌─────────┐ ┌──────────┐ ┌─────────┐ ┌───────────┐
|
||||
│ 입금 │ ──▶ │ 대기 │ ──▶ │ 승인 │ ──▶ │ 지급완료 │
|
||||
│ 등록 │ │ pending │ │ approved│ │ paid │
|
||||
└─────────┘ └──────────┘ └─────────┘ └───────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────┐
|
||||
│ 취소 │
|
||||
│cancelled │
|
||||
└──────────┘
|
||||
```
|
||||
|
||||
### 3.2 상태별 설명
|
||||
|
||||
| 상태 | 코드 | 설명 |
|
||||
|------|------|------|
|
||||
| **대기** | `pending` | 입금 등록 후 승인 대기 중 |
|
||||
| **승인** | `approved` | 본사 승인 완료, 지급 예정 |
|
||||
| **지급완료** | `paid` | 실제 지급 완료 |
|
||||
| **취소** | `cancelled` | 취소됨 (대기/승인 상태에서만 가능) |
|
||||
|
||||
### 3.3 지급예정일 계산
|
||||
|
||||
```php
|
||||
// 입금일 익월 10일
|
||||
$scheduledPaymentDate = $paymentDate->addMonth()->day(10);
|
||||
```
|
||||
|
||||
**예시:**
|
||||
- 1월 15일 입금 → 2월 10일 지급예정
|
||||
- 1월 31일 입금 → 2월 10일 지급예정
|
||||
|
||||
---
|
||||
|
||||
## 4. 데이터베이스 구조
|
||||
|
||||
### 4.1 sales_commissions 테이블
|
||||
|
||||
```sql
|
||||
CREATE TABLE sales_commissions (
|
||||
id BIGINT UNSIGNED PRIMARY KEY,
|
||||
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||
management_id BIGINT UNSIGNED NOT NULL,
|
||||
|
||||
-- 입금 정보
|
||||
payment_type ENUM('deposit', 'balance') NOT NULL,
|
||||
payment_amount DECIMAL(15,2) NOT NULL,
|
||||
payment_date DATE NOT NULL,
|
||||
|
||||
-- 수당 계산
|
||||
base_amount DECIMAL(15,2) NOT NULL, -- 기준 금액 (가입비의 50%)
|
||||
partner_rate DECIMAL(5,2) DEFAULT 20.00, -- 판매자 수당률
|
||||
manager_rate DECIMAL(5,2) DEFAULT 5.00, -- 매니저 수당률
|
||||
partner_commission DECIMAL(15,2) NOT NULL, -- 판매자 수당액
|
||||
manager_commission DECIMAL(15,2) NOT NULL, -- 매니저 수당액
|
||||
|
||||
-- 지급 정보
|
||||
scheduled_payment_date DATE NOT NULL, -- 지급예정일 (익월 10일)
|
||||
actual_payment_date DATE NULL, -- 실제 지급일
|
||||
status ENUM('pending', 'approved', 'paid', 'cancelled'),
|
||||
|
||||
-- 담당자
|
||||
partner_id BIGINT UNSIGNED NOT NULL, -- 영업파트너 ID
|
||||
manager_user_id BIGINT UNSIGNED NULL, -- 매니저 사용자 ID
|
||||
|
||||
-- 승인 정보
|
||||
approved_by BIGINT UNSIGNED NULL,
|
||||
approved_at TIMESTAMP NULL,
|
||||
|
||||
-- 기타
|
||||
bank_reference VARCHAR(100) NULL, -- 이체 참조번호
|
||||
notes TEXT NULL,
|
||||
|
||||
created_at TIMESTAMP,
|
||||
updated_at TIMESTAMP,
|
||||
deleted_at TIMESTAMP NULL
|
||||
);
|
||||
```
|
||||
|
||||
### 4.2 sales_commission_details 테이블 (상품별 상세)
|
||||
|
||||
```sql
|
||||
CREATE TABLE sales_commission_details (
|
||||
id BIGINT UNSIGNED PRIMARY KEY,
|
||||
commission_id BIGINT UNSIGNED NOT NULL,
|
||||
contract_product_id BIGINT UNSIGNED NOT NULL,
|
||||
|
||||
registration_fee DECIMAL(15,2) NOT NULL, -- 상품 가입비
|
||||
base_amount DECIMAL(15,2) NOT NULL, -- 기준 금액
|
||||
partner_rate DECIMAL(5,2) NOT NULL, -- 상품별 판매자 수당률
|
||||
manager_rate DECIMAL(5,2) NOT NULL, -- 상품별 매니저 수당률
|
||||
partner_commission DECIMAL(15,2) NOT NULL, -- 판매자 수당액
|
||||
manager_commission DECIMAL(15,2) NOT NULL, -- 매니저 수당액
|
||||
|
||||
created_at TIMESTAMP,
|
||||
updated_at TIMESTAMP
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 서비스 클래스
|
||||
|
||||
### 5.1 SalesCommissionService
|
||||
|
||||
경로: `app/Services/SalesCommissionService.php`
|
||||
|
||||
#### 주요 메서드
|
||||
|
||||
| 메서드 | 설명 |
|
||||
|--------|------|
|
||||
| `createCommission()` | 입금 등록 시 수당 생성 |
|
||||
| `approve()` | 수당 승인 처리 |
|
||||
| `markAsPaid()` | 지급완료 처리 |
|
||||
| `bulkApprove()` | 일괄 승인 |
|
||||
| `bulkMarkAsPaid()` | 일괄 지급완료 |
|
||||
| `cancel()` | 취소 처리 |
|
||||
| `getPartnerCommissionSummary()` | 영업파트너 수당 요약 |
|
||||
| `getManagerCommissionSummary()` | 매니저 수당 요약 |
|
||||
|
||||
#### 수당 생성 예시
|
||||
|
||||
```php
|
||||
$commission = $this->commissionService->createCommission(
|
||||
managementId: $management->id,
|
||||
paymentType: 'deposit', // 계약금
|
||||
paymentAmount: 5000000, // 500만원
|
||||
paymentDate: '2026-01-30'
|
||||
);
|
||||
```
|
||||
|
||||
### 5.2 수당 요약 조회
|
||||
|
||||
```php
|
||||
// 영업파트너 요약
|
||||
$summary = $this->commissionService->getPartnerCommissionSummary($partnerId);
|
||||
// [
|
||||
// 'scheduled_this_month' => 1000000, // 이번 달 지급예정
|
||||
// 'total_received' => 5000000, // 누적 수령
|
||||
// 'pending_amount' => 500000, // 대기중
|
||||
// 'contracts_this_month' => 3, // 이번 달 계약 건수
|
||||
// ]
|
||||
|
||||
// 매니저 요약
|
||||
$summary = $this->commissionService->getManagerCommissionSummary($managerUserId);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 대시보드 통계
|
||||
|
||||
### 6.1 영업파트너 대시보드
|
||||
|
||||
경로: `/sales/salesmanagement/dashboard`
|
||||
|
||||
#### 표시 항목
|
||||
|
||||
| 항목 | 설명 |
|
||||
|------|------|
|
||||
| 총 가입비 | 나와 관련된 계약의 총 입금액 |
|
||||
| 총 수당 | 판매자 수당 + 매니저 수당 합계 |
|
||||
| 지급 완료 비율 | (지급완료 수당 / 총 수당) × 100 |
|
||||
| 전체 건수 | 관련 계약 건수 |
|
||||
| 승인 대기 | pending 상태 건수 |
|
||||
| 지급 대기 | approved 상태 건수 |
|
||||
|
||||
#### 역할별 수당 표시
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ 판매자 수당 (20%) │
|
||||
│ ├─ 총액: 1,000,000원 │
|
||||
│ ├─ 지급완료: 500,000원 │
|
||||
│ ├─ 승인완료: 300,000원 │
|
||||
│ └─ 대기중: 200,000원 │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ 매니저 수당 (5%) │
|
||||
│ ├─ 총액: 250,000원 │
|
||||
│ ├─ 지급완료: 100,000원 │
|
||||
│ ├─ 승인완료: 100,000원 │
|
||||
│ └─ 대기중: 50,000원 │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 6.2 내 계약 현황 조회 범위
|
||||
|
||||
대시보드에 표시되는 계약:
|
||||
1. **내가 등록한 가망고객** → 전환된 테넌트 (판매자 수당 20%)
|
||||
2. **내 하위 파트너가 등록한 가망고객** → 전환된 테넌트
|
||||
3. **내가 매니저로 지정된 계약** (매니저 수당 5%)
|
||||
|
||||
```php
|
||||
// 1) 내가 등록한 가망고객에서 전환된 tenant_id
|
||||
$registeredTenantIds = TenantProspect::whereIn('registered_by', $partnerIds)
|
||||
->where('status', 'converted')
|
||||
->pluck('tenant_id');
|
||||
|
||||
// 2) 내가 매니저로 지정된 tenant_id
|
||||
$managedTenantIds = SalesTenantManagement::where('manager_user_id', $currentUserId)
|
||||
->pluck('tenant_id');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. API 엔드포인트
|
||||
|
||||
### 7.1 수당 정산 관리
|
||||
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| GET | `/sales/commissions` | 정산 목록 조회 |
|
||||
| GET | `/sales/commissions/{id}` | 정산 상세 조회 |
|
||||
| POST | `/sales/commissions` | 입금 등록 (수당 생성) |
|
||||
| POST | `/sales/commissions/{id}/approve` | 승인 처리 |
|
||||
| POST | `/sales/commissions/{id}/paid` | 지급완료 처리 |
|
||||
| POST | `/sales/commissions/{id}/cancel` | 취소 처리 |
|
||||
| POST | `/sales/commissions/bulk-approve` | 일괄 승인 |
|
||||
| POST | `/sales/commissions/bulk-paid` | 일괄 지급완료 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 관련 파일
|
||||
|
||||
### 모델
|
||||
```
|
||||
app/Models/Sales/SalesCommission.php # 수당 정산 모델
|
||||
app/Models/Sales/SalesCommissionDetail.php # 수당 상세 내역
|
||||
app/Models/Sales/SalesPartner.php # 영업파트너 (누적 수당 저장)
|
||||
app/Models/Sales/SalesTenantManagement.php # 테넌트별 영업 관리
|
||||
```
|
||||
|
||||
### 서비스
|
||||
```
|
||||
app/Services/SalesCommissionService.php # 수당 정산 서비스
|
||||
```
|
||||
|
||||
### 컨트롤러
|
||||
```
|
||||
app/Http/Controllers/Sales/SalesCommissionController.php # 수당 정산 관리
|
||||
app/Http/Controllers/Sales/SalesDashboardController.php # 대시보드
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 변경 이력
|
||||
|
||||
| 날짜 | 변경 내용 | 작성자 |
|
||||
|------|----------|--------|
|
||||
| 2026-01-30 | 최초 작성 | Claude |
|
||||
|
||||
---
|
||||
|
||||
> **참고:** 이 문서는 수당 관련 기능 개발 시 기준 문서로 사용됩니다.
|
||||
> 수당 정책 변경 시 반드시 이 문서를 먼저 업데이트하세요.
|
||||
328
guides/영업파트너구조.md
Normal file
328
guides/영업파트너구조.md
Normal file
@@ -0,0 +1,328 @@
|
||||
# 영업파트너 구조 설계서
|
||||
|
||||
> SAM 프로젝트 영업관리 시스템의 핵심 구조 문서
|
||||
>
|
||||
> 최종 수정: 2026-01-30
|
||||
|
||||
---
|
||||
|
||||
## 1. 개요
|
||||
|
||||
### 1.1 목적
|
||||
이 문서는 SAM 영업관리 시스템의 **영업파트너 조직 구조**를 정의합니다.
|
||||
모든 영업 관련 기능 개발 시 이 구조를 기준으로 해석하고 구현합니다.
|
||||
|
||||
### 1.2 핵심 원칙
|
||||
|
||||
| 원칙 | 설명 |
|
||||
|------|------|
|
||||
| **직위 단일화** | 모든 영업 담당자는 "영업파트너"라는 동일한 직위 |
|
||||
| **계층 무한 확장** | 상위-하위 유치 관계는 무한 깊이까지 허용 |
|
||||
| **역할 분리** | 직위와 역할을 분리하여 유연한 업무 할당 |
|
||||
| **역할 위임 가능** | 상위 파트너가 하위 파트너에게 역할 위임 가능 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 핵심 개념 정의
|
||||
|
||||
### 2.1 직위 (Position)
|
||||
- **영업파트너**: 모든 영업 담당자의 공통 직위
|
||||
- 별도의 직위 구분 없음 (매니저, 팀장 등은 역할로 처리)
|
||||
|
||||
### 2.2 계층 (Hierarchy)
|
||||
- **유치 관계**: 상위 파트너가 하위 파트너를 유치(추천)
|
||||
- **parent_id**: 나를 유치한 상위 파트너
|
||||
- **레벨**: 최상위(레벨1)부터 무한 깊이까지
|
||||
|
||||
```
|
||||
레벨1: 최상위 영업파트너 (parent_id = null)
|
||||
레벨2: 레벨1이 유치한 파트너
|
||||
레벨3: 레벨2가 유치한 파트너
|
||||
...
|
||||
레벨N: 무한 확장 가능
|
||||
```
|
||||
|
||||
### 2.3 역할 (Role)
|
||||
직위와 별개로 **수행하는 업무**를 정의합니다.
|
||||
|
||||
| 역할 코드 | 역할명 | 설명 |
|
||||
|-----------|--------|------|
|
||||
| `sales` | 영업 | 가망고객 발굴, 상담, 계약 체결 |
|
||||
| `manager` | 매니저 | 하위 파트너 관리, 실적 취합, 승인 처리 |
|
||||
| `recruiter` | 유치담당 | 새로운 영업파트너 유치 활동 |
|
||||
|
||||
**특징:**
|
||||
- 한 파트너가 **복수의 역할** 보유 가능
|
||||
- 역할은 **위임 가능** (상위 → 하위)
|
||||
- 역할에 따라 **수당 구조**가 달라질 수 있음
|
||||
|
||||
---
|
||||
|
||||
## 3. 조직 구조 예시
|
||||
|
||||
### 3.1 기본 구조
|
||||
|
||||
```
|
||||
영업파트너 김철수 (레벨1, parent_id: null)
|
||||
│ 역할: sales, manager, recruiter
|
||||
│
|
||||
├── 영업파트너 이영희 (레벨2, parent_id: 김철수)
|
||||
│ │ 역할: sales, recruiter
|
||||
│ │
|
||||
│ ├── 영업파트너 박지민 (레벨3, parent_id: 이영희)
|
||||
│ │ 역할: sales
|
||||
│ │
|
||||
│ └── 영업파트너 최민수 (레벨3, parent_id: 이영희)
|
||||
│ 역할: sales
|
||||
│
|
||||
└── 영업파트너 정수연 (레벨2, parent_id: 김철수)
|
||||
역할: sales, manager ← 김철수가 매니저 역할 위임
|
||||
```
|
||||
|
||||
### 3.2 역할 위임 시나리오
|
||||
|
||||
**시나리오: 김철수가 매니저 역할을 정수연에게 위임**
|
||||
|
||||
| 변경 전 | 변경 후 |
|
||||
|---------|---------|
|
||||
| 김철수: sales, **manager**, recruiter | 김철수: sales, recruiter |
|
||||
| 정수연: sales | 정수연: sales, **manager** |
|
||||
|
||||
**결과:**
|
||||
- 정수연이 김철수 하위 파트너들의 관리 업무 수행
|
||||
- 수당 구조에 따라 매니저 수당도 정수연에게 지급
|
||||
|
||||
---
|
||||
|
||||
## 4. 수당/수익 구조
|
||||
|
||||
> **상세 내용:** [수당지급.md](./수당지급.md) 참조
|
||||
|
||||
### 4.1 수당 유형
|
||||
|
||||
| 수당 유형 | 수당률/금액 | 지급 대상 | 설명 |
|
||||
|-----------|-------------|-----------|------|
|
||||
| **판매자 수당** | 20% | 가망고객 등록자 | 가입비의 50% × 20% |
|
||||
| **매니저 수당** | 5% | 지정된 매니저 | 가입비의 50% × 5% |
|
||||
| **협업지원금** | 메뉴당 2,000원 | 2단계 상위 파트너 | 가입비 완납 시 지급 |
|
||||
|
||||
### 4.2 수당 계산 원칙
|
||||
|
||||
```
|
||||
기준 금액 = 총 가입비의 50%
|
||||
|
||||
1. 판매자 수당: 기준 금액 × 20% (가망고객 등록자)
|
||||
2. 매니저 수당: 기준 금액 × 5% (매니저로 지정된 파트너)
|
||||
```
|
||||
|
||||
### 4.3 수당 흐름 예시
|
||||
|
||||
```
|
||||
고객 계약 (가입비 1,000만원)
|
||||
└─ 기준 금액: 500만원 (가입비의 50%)
|
||||
|
||||
김철수 (가망고객 등록자, 판매자)
|
||||
→ 판매자 수당: 500만원 × 20% = 100만원
|
||||
|
||||
이영희 (김철수가 지정한 매니저)
|
||||
→ 매니저 수당: 500만원 × 5% = 25만원
|
||||
```
|
||||
|
||||
### 4.4 수당 지급 프로세스
|
||||
|
||||
```
|
||||
1. 입금 등록 → SalesCommission 생성 (status: pending)
|
||||
2. 본사 승인 → status: approved
|
||||
3. 지급 완료 → status: paid + 누적 수당 업데이트
|
||||
```
|
||||
|
||||
> **참고:** 자세한 수당 시스템 구현 내용은 [수당지급.md](./수당지급.md) 참조
|
||||
|
||||
---
|
||||
|
||||
## 5. 데이터베이스 구조
|
||||
|
||||
### 5.1 users 테이블 (기존 + 확장)
|
||||
|
||||
```sql
|
||||
-- 기존 컬럼
|
||||
id, user_id, name, email, phone, password, is_active, ...
|
||||
|
||||
-- 영업파트너 확장 컬럼
|
||||
parent_id -- 상위 파트너 (유치자) ID
|
||||
approval_status -- 승인 상태: pending, approved, rejected
|
||||
approved_by -- 승인 처리자 ID
|
||||
approved_at -- 승인 일시
|
||||
rejection_reason -- 반려 사유
|
||||
```
|
||||
|
||||
### 5.2 user_roles 테이블
|
||||
|
||||
```sql
|
||||
id
|
||||
user_id -- 사용자 ID
|
||||
tenant_id -- 테넌트 ID
|
||||
role_id -- 역할 ID (roles 테이블 참조)
|
||||
assigned_at -- 역할 할당 일시
|
||||
assigned_by -- 역할 할당자 (위임 시)
|
||||
```
|
||||
|
||||
### 5.3 roles 테이블 (영업 관련)
|
||||
|
||||
| id | name | description |
|
||||
|----|------|-------------|
|
||||
| - | sales | 영업 - 가망고객 발굴, 계약 체결 |
|
||||
| - | manager | 매니저 - 하위 파트너 관리, 승인 |
|
||||
| - | recruiter | 유치담당 - 신규 파트너 유치 |
|
||||
|
||||
### 5.4 sales_manager_documents 테이블
|
||||
|
||||
```sql
|
||||
id
|
||||
tenant_id
|
||||
user_id -- 영업파트너 ID
|
||||
file_path -- 파일 저장 경로
|
||||
original_name -- 원본 파일명
|
||||
document_type -- 문서 유형: id_card, business_license, contract, etc.
|
||||
description -- 설명
|
||||
uploaded_by -- 업로더
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 기능 구현 현황
|
||||
|
||||
### 6.1 완료된 기능
|
||||
|
||||
- [x] 영업파트너 등록 (User 통합)
|
||||
- [x] 상위-하위 계층 구조 (parent_id)
|
||||
- [x] 역할 기반 시스템 (sales, manager, recruiter)
|
||||
- [x] 멀티파일 업로드 (첨부 서류)
|
||||
- [x] 본사 승인 프로세스 (pending → approved/rejected)
|
||||
- [x] 역할 위임 기능 (상위 → 하위)
|
||||
- [x] 역할 부여/제거 기능
|
||||
- [x] 추천인(유치자) 관리
|
||||
- [x] **수당 자동 계산 (판매자 20%, 매니저 5%)**
|
||||
- [x] **수당 정산 시스템 (SalesCommission)**
|
||||
- [x] **수당 승인/지급 프로세스**
|
||||
- [x] **대시보드 통계 (실적, 수당 현황)**
|
||||
- [x] **가망고객 등록/관리**
|
||||
- [x] **테넌트 전환 프로세스**
|
||||
|
||||
### 6.2 구현 예정 기능
|
||||
|
||||
- [ ] 조직도 시각화 (트리 뷰)
|
||||
- [ ] 유치 실적 관리
|
||||
- [ ] 성과 분석 리포트
|
||||
|
||||
---
|
||||
|
||||
## 7. 개발 로드맵
|
||||
|
||||
### Phase 1: 기반 구조 (완료)
|
||||
- 영업파트너 = User 통합
|
||||
- parent_id 계층 구조
|
||||
- 역할 시스템 (roles)
|
||||
- 승인 프로세스
|
||||
|
||||
### Phase 2: 역할 위임 기능
|
||||
- 역할 위임 UI
|
||||
- 위임 이력 관리
|
||||
- 위임 알림
|
||||
|
||||
### Phase 3: 수당 시스템
|
||||
- 수당 정책 설정
|
||||
- 계층별 수당 자동 계산
|
||||
- 수당 지급 승인 프로세스
|
||||
- 수당 내역 조회
|
||||
|
||||
### Phase 4: 조직 관리 고도화
|
||||
- 조직도 시각화 (트리 구조)
|
||||
- 하위 파트너 실적 대시보드
|
||||
- 유치 실적 통계
|
||||
- 성과 분석 리포트
|
||||
|
||||
### Phase 5: 파트너 포털
|
||||
- 영업파트너 전용 앱/웹
|
||||
- 본인 실적 조회
|
||||
- 하위 파트너 현황
|
||||
- 수당 내역 조회
|
||||
|
||||
---
|
||||
|
||||
## 8. 용어 정리
|
||||
|
||||
| 용어 | 정의 |
|
||||
|------|------|
|
||||
| **영업파트너** | SAM 영업 조직의 모든 구성원 (직위) |
|
||||
| **상위 파트너** | 나를 유치한 파트너 (parent) |
|
||||
| **하위 파트너** | 내가 유치한 파트너 (children) |
|
||||
| **유치** | 새로운 영업파트너를 조직에 등록시키는 행위 |
|
||||
| **위임** | 상위 파트너가 하위 파트너에게 역할을 넘기는 행위 |
|
||||
| **레벨** | 최상위부터의 계층 깊이 (레벨1 = 최상위) |
|
||||
| **영업 역할** | 가망고객 발굴, 계약 체결 업무 |
|
||||
| **매니저 역할** | 하위 파트너 관리, 승인 업무 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 관련 파일 경로
|
||||
|
||||
### MNG 프로젝트
|
||||
|
||||
#### 모델
|
||||
```
|
||||
app/Models/User.php # 사용자 모델 (영업파트너, parent_id)
|
||||
app/Models/Sales/SalesPartner.php # 영업파트너 정보
|
||||
app/Models/Sales/SalesManagerDocument.php # 첨부 서류 모델
|
||||
app/Models/Sales/SalesCommission.php # 수당 정산 모델
|
||||
app/Models/Sales/SalesCommissionDetail.php # 수당 상세 내역
|
||||
app/Models/Sales/SalesTenantManagement.php # 테넌트별 영업 관리
|
||||
app/Models/Sales/TenantProspect.php # 가망고객 모델
|
||||
```
|
||||
|
||||
#### 서비스
|
||||
```
|
||||
app/Services/SalesCommissionService.php # 수당 정산 서비스
|
||||
app/Services/Sales/SalesManagerService.php # 영업파트너 서비스
|
||||
```
|
||||
|
||||
#### 컨트롤러
|
||||
```
|
||||
app/Http/Controllers/Sales/SalesManagerController.php # 영업파트너 관리
|
||||
app/Http/Controllers/Sales/SalesDashboardController.php # 대시보드
|
||||
app/Http/Controllers/Sales/SalesProspectController.php # 가망고객 관리
|
||||
app/Http/Controllers/Sales/SalesCommissionController.php # 수당 정산
|
||||
```
|
||||
|
||||
#### 뷰
|
||||
```
|
||||
resources/views/sales/managers/ # 영업파트너 관리
|
||||
resources/views/sales/dashboard/ # 대시보드
|
||||
resources/views/sales/prospects/ # 가망고객 관리
|
||||
resources/views/sales/commissions/ # 수당 정산
|
||||
```
|
||||
|
||||
### API 프로젝트
|
||||
```
|
||||
database/migrations/2026_01_27_200000_add_sales_manager_fields_to_users_table.php
|
||||
database/migrations/2026_01_27_200100_create_sales_manager_documents_table.php
|
||||
database/migrations/..._create_sales_commissions_table.php
|
||||
database/migrations/..._create_tenant_prospects_table.php
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 변경 이력
|
||||
|
||||
| 날짜 | 변경 내용 | 작성자 |
|
||||
|------|----------|--------|
|
||||
| 2026-01-27 | 최초 작성 | Claude |
|
||||
| 2026-01-27 | 역할 위임/부여/제거 기능 구현 완료 | Claude |
|
||||
| 2026-01-30 | 수당 구조 업데이트 (판매자 20%, 매니저 5%) | Claude |
|
||||
| 2026-01-30 | 수당 시스템 구현 완료 반영 | Claude |
|
||||
| 2026-01-30 | 관련 파일 경로 업데이트 | Claude |
|
||||
|
||||
---
|
||||
|
||||
> **참고:** 이 문서는 영업 관련 기능 개발 시 기준 문서로 사용됩니다.
|
||||
> 구조 변경 시 반드시 이 문서를 먼저 업데이트하세요.
|
||||
164
guides/홈택스 매입매출 조회성공.md
Normal file
164
guides/홈택스 매입매출 조회성공.md
Normal file
@@ -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. **에러 발생 시 바로빌 고객센터 문의** - 로그를 분석해서 정확한 원인을 알려줌
|
||||
Reference in New Issue
Block a user