Merge remote-tracking branch 'origin/develop' into develop

This commit is contained in:
2026-01-30 18:08:33 +09:00
6 changed files with 753 additions and 141 deletions

View File

@@ -3,6 +3,7 @@
namespace App\Http\Controllers\Sales;
use App\Http\Controllers\Controller;
use App\Models\Sales\SalesCommission;
use App\Models\Sales\SalesPartner;
use App\Models\Sales\SalesTenantManagement;
use App\Models\Sales\TenantProspect;
@@ -64,56 +65,107 @@ private function getDashboardData(Request $request): array
$endDate = now()->endOfMonth()->format('Y-m-d');
}
// 통계 데이터 (임시 데이터 - 추후 실제 데이터로 교체)
$currentUserId = auth()->id();
$childrenIds = auth()->user()->children()->pluck('id')->toArray();
$partnerIds = array_merge([$currentUserId], $childrenIds);
// 현재 사용자의 영업파트너 정보 조회
$partner = SalesPartner::where('user_id', $currentUserId)->first();
$partnerId = $partner?->id;
// 나와 관련된 모든 수당 조회 (영업파트너로서 + 매니저로서)
$myCommissionsAsPartner = $partnerId
? SalesCommission::forPartner($partnerId)->get()
: collect();
$myCommissionsAsManager = SalesCommission::forManager($currentUserId)->get();
// 판매자(영업파트너) 수당 계산
$partnerCommissionTotal = $myCommissionsAsPartner->sum('partner_commission');
$partnerCommissionPaid = $myCommissionsAsPartner->where('status', SalesCommission::STATUS_PAID)->sum('partner_commission');
$partnerCommissionPending = $myCommissionsAsPartner->where('status', SalesCommission::STATUS_PENDING)->sum('partner_commission');
$partnerCommissionApproved = $myCommissionsAsPartner->where('status', SalesCommission::STATUS_APPROVED)->sum('partner_commission');
// 매니저 수당 계산
$managerCommissionTotal = $myCommissionsAsManager->sum('manager_commission');
$managerCommissionPaid = $myCommissionsAsManager->where('status', SalesCommission::STATUS_PAID)->sum('manager_commission');
$managerCommissionPending = $myCommissionsAsManager->where('status', SalesCommission::STATUS_PENDING)->sum('manager_commission');
$managerCommissionApproved = $myCommissionsAsManager->where('status', SalesCommission::STATUS_APPROVED)->sum('manager_commission');
// 총 수당 계산 (중복 제거: 동일 commission에서 partner + manager인 경우)
$allCommissionIds = $myCommissionsAsPartner->pluck('id')->merge($myCommissionsAsManager->pluck('id'))->unique();
$totalContracts = $allCommissionIds->count();
// 통계 데이터 (실제 데이터)
$totalMembershipFee = $myCommissionsAsPartner->sum('payment_amount') + $myCommissionsAsManager->sum('payment_amount');
$totalCommission = $partnerCommissionTotal + $managerCommissionTotal;
$paidCommission = $partnerCommissionPaid + $managerCommissionPaid;
$commissionRate = $totalCommission > 0 ? round(($paidCommission / $totalCommission) * 100, 1) : 0;
$stats = [
'total_membership_fee' => 0, // 총 가입비
'total_commission' => 0, // 총 수당
'commission_rate' => 0, // 지급 승인 완료 비율
'total_contracts' => 0, // 전체 건수
'pending_membership_approval' => 0, // 가입 승인 대기
'pending_payment_approval' => 0, // 지급 승인 대기
'total_membership_fee' => $totalMembershipFee, // 총 가입비
'total_commission' => $totalCommission, // 총 수당
'commission_rate' => $commissionRate, // 지급 완료 비율
'total_contracts' => $totalContracts, // 전체 건수
'pending_membership_approval' => $myCommissionsAsPartner->where('status', SalesCommission::STATUS_PENDING)->count()
+ $myCommissionsAsManager->where('status', SalesCommission::STATUS_PENDING)->count(),
'pending_payment_approval' => $myCommissionsAsPartner->where('status', SalesCommission::STATUS_APPROVED)->count()
+ $myCommissionsAsManager->where('status', SalesCommission::STATUS_APPROVED)->count(),
];
// 역할별 수당 상세
// 역할별 수당 상세 (실제 데이터)
$commissionByRole = [
[
'name' => '판매자',
'rate' => 20,
'amount' => 0,
'amount' => $partnerCommissionTotal,
'paid' => $partnerCommissionPaid,
'pending' => $partnerCommissionPending,
'approved' => $partnerCommissionApproved,
'color' => 'green',
],
[
'name' => '관리자',
'rate' => 5,
'amount' => 0,
'amount' => $managerCommissionTotal,
'paid' => $managerCommissionPaid,
'pending' => $managerCommissionPending,
'approved' => $managerCommissionApproved,
'color' => 'blue',
],
[
'name' => '매뉴제작 협업수당',
'rate' => null, // 별도
'amount' => null, // 운영팀 산정
'color' => 'red',
'name' => '협업지원금',
'rate' => null, // 메뉴당 2,000원
'amount' => null, // 가입비 완납 시 계산
'color' => 'purple',
],
];
// 총 가입비 대비 수당
$totalCommissionRatio = 0;
// 총 가입비 대비 수당 비율
$totalCommissionRatio = $totalMembershipFee > 0 ? round(($totalCommission / $totalMembershipFee) * 100, 1) : 0;
// 수익 및 테넌트 관리 통계 (임시 데이터 - 추후 실제 데이터로 교체)
$tenantStats = [
'total_tenants' => 0, // 관리 테넌트
'total_membership_revenue' => 0, // 총 가입비 실적
'total_commission_accumulated' => 0, // 누적 가입비 수당
'confirmed_commission' => 0, // 확정 가입비 수당
];
// 테넌트 목록 (가망고객에서 전환된 테넌트만)
// 전환된 가망고객의 tenant_id 목록 조회
$convertedTenantIds = TenantProspect::whereNotNull('tenant_id')
// 1) 내가 등록한 가망고객에서 전환된 tenant_id (20% 수당)
$registeredTenantIds = TenantProspect::whereNotNull('tenant_id')
->where('status', TenantProspect::STATUS_CONVERTED)
->whereIn('registered_by', $partnerIds)
->pluck('tenant_id')
->toArray();
// 2) 내가 매니저로 지정된 tenant_id (5% 수당)
$managedTenantIds = SalesTenantManagement::where('manager_user_id', $currentUserId)
->pluck('tenant_id')
->toArray();
// 두 목록 합치기 (중복 제거)
$convertedTenantIds = array_unique(array_merge($registeredTenantIds, $managedTenantIds));
// 수익 및 테넌트 관리 통계 (실제 데이터)
$tenantStats = [
'total_tenants' => count($convertedTenantIds), // 관리 테넌트
'total_membership_revenue' => $totalMembershipFee, // 총 가입비 실적
'total_commission_accumulated' => $totalCommission, // 누적 수당
'confirmed_commission' => $paidCommission, // 확정(지급완료) 수당
];
// 전환된 테넌트만 조회 (최신순, 페이지네이션)
$tenants = Tenant::whereIn('id', $convertedTenantIds)
->orderBy('created_at', 'desc')
@@ -198,12 +250,26 @@ public function assignManager(int $tenantId, Request $request): JsonResponse
*/
public function refreshTenantList(Request $request): View
{
// 전환된 가망고객의 tenant_id 목록 조회
$convertedTenantIds = TenantProspect::whereNotNull('tenant_id')
// 테넌트 목록 (나와 연결된 계약만)
$currentUserId = auth()->id();
$childrenIds = auth()->user()->children()->pluck('id')->toArray();
$partnerIds = array_merge([$currentUserId], $childrenIds);
// 1) 내가 등록한 가망고객에서 전환된 tenant_id (20% 수당)
$registeredTenantIds = TenantProspect::whereNotNull('tenant_id')
->where('status', TenantProspect::STATUS_CONVERTED)
->whereIn('registered_by', $partnerIds)
->pluck('tenant_id')
->toArray();
// 2) 내가 매니저로 지정된 tenant_id (5% 수당)
$managedTenantIds = SalesTenantManagement::where('manager_user_id', $currentUserId)
->pluck('tenant_id')
->toArray();
// 두 목록 합치기 (중복 제거)
$convertedTenantIds = array_unique(array_merge($registeredTenantIds, $managedTenantIds));
// 전환된 테넌트만 조회 (최신순, 페이지네이션)
$tenants = Tenant::whereIn('id', $convertedTenantIds)
->orderBy('created_at', 'desc')

View File

@@ -4,6 +4,7 @@
use App\Services\TenantService;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\View\View;
class TenantController extends Controller
@@ -15,8 +16,13 @@ public function __construct(
/**
* 테넌트 목록 (Blade 화면)
*/
public function index(Request $request): View
public function index(Request $request): View|Response
{
// HTMX 요청 시 전체 페이지 리로드 (스크립트 실행 필요)
if ($request->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('tenants.index'));
}
return view('tenants.index');
}

372
claudedocs/수당지급.md Normal file
View 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 |
---
> **참고:** 이 문서는 수당 관련 기능 개발 시 기준 문서로 사용됩니다.
> 수당 정책 변경 시 반드시 이 문서를 먼저 업데이트하세요.

View File

@@ -2,7 +2,7 @@ # 영업파트너 구조 설계서
> SAM 프로젝트 영업관리 시스템의 핵심 구조 문서
>
> 최종 수정: 2026-01-27
> 최종 수정: 2026-01-30
---
@@ -96,38 +96,47 @@ ### 3.2 역할 위임 시나리오
## 4. 수당/수익 구조
> **상세 내용:** [수당지급.md](./수당지급.md) 참조
### 4.1 수당 유형
| 수당 유형 | 기준 | 지급 대상 |
|-----------|------|-----------|
| **영업 수당** | 본인이 체결한 계약 | 계약 체결자 (sales 역할) |
| **관리 수당** | 하위 파트너 실적 | manager 역할 보유자 |
| **유치 수당** | 신규 파트너 유치 | recruiter 역할 보유자 |
| 수당 유형 | 수당률/금액 | 지급 대상 | 설명 |
|-----------|-------------|-----------|------|
| **판매자 수당** | 20% | 가망고객 등록자 | 가입비의 50% × 20% |
| **매니저 수당** | 5% | 지정된 매니저 | 가입비의 50% × 5% |
| **협업지원금** | 메뉴당 2,000원 | 2단계 상위 파트너 | 가입비 완납 시 지급 |
### 4.2 수당 계산 원칙
```
1. 영업 수당: 본인 계약 × 영업 수당률
2. 관리 수당: 하위 N단계 실적 × 단계별 관리 수당률
3. 유치 수당: 유치한 파트너 가입비 × 유치 수당률
기준 금액 = 총 가입비의 50%
1. 판매자 수당: 기준 금액 × 20% (가망고객 등록자)
2. 매니저 수당: 기준 금액 × 5% (매니저로 지정된 파트너)
```
### 4.3 계층별 수당 흐름 예시
### 4.3 수당 흐름 예시
```
고객 계약 100만원 발생 (계약자: 박지민)
고객 계약 (가입비 1,000만원)
└─ 기준 금액: 500만원 (가입비의 50%)
박지민 (레벨3, sales)
영업 수당 20% = 20만원
김철수 (가망고객 등록자, 판매자)
판매자 수당: 500만원 × 20% = 100만원
이영희 (레벨2, 박지민의 상위)
관리 수당 5% = 5만원 (sales 역할이지만 상위로서)
김철수 (레벨1, 이영희의 상위)
→ 관리 수당 2% = 2만원 (레벨 차이에 따른 감소)
이영희 (김철수가 지정한 매니저)
매니저 수당: 500만원 × 5% = 25만원
```
> **주의:** 실제 수당률은 정책에 따라 변경될 수 있음
### 4.4 수당 지급 프로세스
```
1. 입금 등록 → SalesCommission 생성 (status: pending)
2. 본사 승인 → status: approved
3. 지급 완료 → status: paid + 누적 수당 업데이트
```
> **참고:** 자세한 수당 시스템 구현 내용은 [수당지급.md](./수당지급.md) 참조
---
@@ -193,14 +202,18 @@ ### 6.1 완료된 기능
- [x] 역할 위임 기능 (상위 → 하위)
- [x] 역할 부여/제거 기능
- [x] 추천인(유치자) 관리
- [x] **수당 자동 계산 (판매자 20%, 매니저 5%)**
- [x] **수당 정산 시스템 (SalesCommission)**
- [x] **수당 승인/지급 프로세스**
- [x] **대시보드 통계 (실적, 수당 현황)**
- [x] **가망고객 등록/관리**
- [x] **테넌트 전환 프로세스**
### 6.2 구현 예정 기능
- [ ] 계층별 수당 자동 계산
- [ ] 조직도 시각화 (트리 뷰)
- [ ] 하위 파트너 실적 대시보드
- [ ] 유치 실적 관리
- [ ] 수당 지급 내역 관리
- [ ] 성과 분석 리포트
---
@@ -255,18 +268,46 @@ ## 8. 용어 정리
## 9. 관련 파일 경로
### MNG 프로젝트
#### 모델
```
app/Models/User.php # 사용자 모델 (영업파트너)
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
resources/views/sales/managers/ # 뷰 파일들
```
#### 컨트롤러
```
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
```
---
@@ -277,6 +318,9 @@ ## 10. 변경 이력
|------|----------|--------|
| 2026-01-27 | 최초 작성 | Claude |
| 2026-01-27 | 역할 위임/부여/제거 기능 구현 완료 | Claude |
| 2026-01-30 | 수당 구조 업데이트 (판매자 20%, 매니저 5%) | Claude |
| 2026-01-30 | 수당 시스템 구현 완료 반영 | Claude |
| 2026-01-30 | 관련 파일 경로 업데이트 | Claude |
---

View File

@@ -50,61 +50,8 @@
const XCircle = createIcon('x-circle');
function CorporateCardsManagement() {
// 카드 목록 데이터
const [cards, setCards] = useState([
{
id: 1,
cardName: '업무용 법인카드',
cardCompany: '삼성카드',
cardNumber: '9410-1234-5678-9012',
cardType: 'credit',
paymentDay: 15,
creditLimit: 10000000,
currentUsage: 3500000,
holder: '김대표',
status: 'active',
memo: '일반 업무용'
},
{
id: 2,
cardName: '마케팅 법인카드',
cardCompany: '현대카드',
cardNumber: '5412-9876-5432-1098',
cardType: 'credit',
paymentDay: 25,
creditLimit: 5000000,
currentUsage: 2100000,
holder: '박마케팅',
status: 'active',
memo: '마케팅/광고비 전용'
},
{
id: 3,
cardName: '개발팀 체크카드',
cardCompany: '국민카드',
cardNumber: '4532-1111-2222-3333',
cardType: 'debit',
paymentDay: 0,
creditLimit: 0,
currentUsage: 850000,
holder: '이개발',
status: 'active',
memo: '개발 장비/소프트웨어 구매'
},
{
id: 4,
cardName: '예비 법인카드',
cardCompany: '신한카드',
cardNumber: '9876-5555-4444-3333',
cardType: 'credit',
paymentDay: 10,
creditLimit: 3000000,
currentUsage: 0,
holder: '최관리',
status: 'inactive',
memo: '비상용'
}
]);
// 카드 목록 데이터 (빈 배열로 시작 - 실제 데이터는 서버 연동 후 로드)
const [cards, setCards] = useState([]);
const [searchTerm, setSearchTerm] = useState('');
const [filterStatus, setFilterStatus] = useState('all');
@@ -120,7 +67,10 @@ function CorporateCardsManagement() {
cardType: 'credit',
paymentDay: 15,
creditLimit: '',
holder: '',
cardHolderName: '',
actualUser: '',
expiryDate: '',
cvc: '',
status: 'active',
memo: ''
};
@@ -158,7 +108,8 @@ function CorporateCardsManagement() {
const filteredCards = cards.filter(card => {
const matchesSearch = card.cardName.toLowerCase().includes(searchTerm.toLowerCase()) ||
card.cardCompany.toLowerCase().includes(searchTerm.toLowerCase()) ||
card.holder.toLowerCase().includes(searchTerm.toLowerCase());
card.actualUser.toLowerCase().includes(searchTerm.toLowerCase()) ||
(card.cardHolderName && card.cardHolderName.toLowerCase().includes(searchTerm.toLowerCase()));
const matchesStatus = filterStatus === 'all' || card.status === filterStatus;
return matchesSearch && matchesStatus;
});
@@ -181,7 +132,10 @@ function CorporateCardsManagement() {
cardType: card.cardType,
paymentDay: card.paymentDay,
creditLimit: card.creditLimit,
holder: card.holder,
cardHolderName: card.cardHolderName || '',
actualUser: card.actualUser || '',
expiryDate: card.expiryDate || '',
cvc: card.cvc || '',
status: card.status,
memo: card.memo
});
@@ -190,7 +144,7 @@ function CorporateCardsManagement() {
// 카드 저장
const handleSaveCard = () => {
if (!formData.cardName || !formData.cardNumber || !formData.holder) {
if (!formData.cardName || !formData.cardNumber || !formData.cardHolderName || !formData.actualUser) {
alert('필수 항목을 입력해주세요.');
return;
}
@@ -215,9 +169,22 @@ function CorporateCardsManagement() {
setEditingCard(null);
};
// 카드 삭제
const handleDeleteCard = (id) => {
if (confirm('정말 삭제하시겠습니까?')) {
// 카드 비활성화 (소프트 삭제)
const handleDeactivateCard = (id) => {
if (confirm('카드를 비활성화하시겠습니까?\n(목록에서 숨겨지지만 데이터는 유지됩니다)')) {
setCards(prev => prev.map(card =>
card.id === id ? { ...card, status: 'inactive' } : card
));
if (showModal) {
setShowModal(false);
setEditingCard(null);
}
}
};
// 카드 영구삭제
const handlePermanentDeleteCard = (id) => {
if (confirm('⚠️ 카드를 영구 삭제하시겠습니까?\n\n이 작업은 되돌릴 수 없습니다.')) {
setCards(prev => prev.filter(card => card.id !== id));
if (showModal) {
setShowModal(false);
@@ -312,7 +279,7 @@ className="flex items-center gap-2 px-4 py-2 bg-violet-600 hover:bg-violet-700 t
<Search className="w-5 h-5 text-gray-400 absolute left-3 top-1/2 -translate-y-1/2" />
<input
type="text"
placeholder="카드명, 카드사, 사용자 검색..."
placeholder="카드명, 카드사, 이용자명, 실사용자 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-violet-500 focus:border-violet-500"
@@ -380,8 +347,16 @@ className={`bg-white rounded-xl border-2 p-6 cursor-pointer transition-all hover
<div className="grid grid-cols-2 gap-4 mb-4 text-sm">
<div>
<p className="text-gray-500">용자</p>
<p className="font-medium text-gray-900">{card.holder}</p>
<p className="text-gray-500">용자</p>
<p className="font-medium text-gray-900">{card.cardHolderName || '-'}</p>
</div>
<div>
<p className="text-gray-500">실사용자</p>
<p className="font-medium text-gray-900">{card.actualUser}</p>
</div>
<div>
<p className="text-gray-500">유효기간(년도/)</p>
<p className="font-medium text-gray-900">{card.expiryDate || '-'}</p>
</div>
<div>
<p className="text-gray-500">카드종류</p>
@@ -487,28 +462,60 @@ className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">카드번호 *</label>
<input
type="text"
value={formData.cardNumber}
onChange={(e) => setFormData(prev => ({ ...prev, cardNumber: e.target.value }))}
placeholder="1234-5678-9012-3456"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-violet-500 font-mono"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">사용자 *</label>
<label className="block text-sm font-medium text-gray-700 mb-1">카드번호 *</label>
<input
type="text"
value={formData.holder}
onChange={(e) => setFormData(prev => ({ ...prev, holder: e.target.value }))}
placeholder="카드 사용자명"
value={formData.cardNumber}
onChange={(e) => setFormData(prev => ({ ...prev, cardNumber: e.target.value }))}
placeholder="1234-5678-9012-3456"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-violet-500 font-mono"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">이용자명 *</label>
<input
type="text"
value={formData.cardHolderName}
onChange={(e) => setFormData(prev => ({ ...prev, cardHolderName: e.target.value }))}
placeholder="카드 명의자 (법인명)"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-violet-500"
/>
</div>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">유효기간(년도/)</label>
<input
type="text"
value={formData.expiryDate}
onChange={(e) => {
let val = e.target.value.replace(/[^\d]/g, '');
if (val.length > 4) val = val.slice(0, 4);
if (val.length >= 2) val = val.slice(0, 2) + '/' + val.slice(2);
setFormData(prev => ({ ...prev, expiryDate: val }));
}}
placeholder="YY/MM"
maxLength={5}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-violet-500 font-mono text-center"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">CVC</label>
<input
type="text"
value={formData.cvc}
onChange={(e) => {
const val = e.target.value.replace(/[^\d]/g, '').slice(0, 3);
setFormData(prev => ({ ...prev, cvc: val }));
}}
placeholder="3자리"
maxLength={3}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-violet-500 font-mono text-center"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">상태</label>
<select
@@ -522,6 +529,17 @@ className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">실사용자 *</label>
<input
type="text"
value={formData.actualUser}
onChange={(e) => setFormData(prev => ({ ...prev, actualUser: e.target.value }))}
placeholder="실제 카드 사용자명"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-violet-500"
/>
</div>
{formData.cardType === 'credit' && (
<div className="grid grid-cols-2 gap-4">
<div>
@@ -563,12 +581,22 @@ className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus
<div className="flex gap-3 mt-6">
{modalMode === 'edit' && (
<button
onClick={() => handleDeleteCard(editingCard.id)}
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg flex items-center gap-2"
>
<span>🗑️</span> 삭제
</button>
<>
<button
onClick={() => handleDeactivateCard(editingCard.id)}
className="px-4 py-2 bg-gray-500 hover:bg-gray-600 text-white rounded-lg flex items-center gap-2"
title="비활성화 (데이터 유지)"
>
<span>🚫</span> 비활성화
</button>
<button
onClick={() => handlePermanentDeleteCard(editingCard.id)}
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg flex items-center gap-2"
title="영구삭제 (복구불가)"
>
<span>🗑️</span> 영구삭제
</button>
</>
)}
<button
onClick={() => { setShowModal(false); setEditingCard(null); }}

View File

@@ -423,3 +423,99 @@ ## 참고: Docker vs 서버 경로 차이
| 공유 스토리지 | `/var/www/shared-storage/` | `/home/webservice/shared-storage/` |
서버에 새로운 설정을 추가할 때는 경로 차이를 반드시 확인해야 합니다.
---
### 14. 영업관리 - 대시보드 메뉴 정렬 순서 수정
**문제**: 대시보드 메뉴가 "영업담당자 관리" 아래에 표시됨 (가장 위에 있어야 함)
**작업 내용** (tinker 사용):
```bash
cd /home/webservice/mng
php artisan tinker --execute='
use App\Models\Commons\Menu;
// 대시보드를 sort_order -1로 설정 (가장 위)
Menu::where("tenant_id", 1)->where("parent_id", 15385)->where("url", "/sales/salesmanagement/dashboard")->update(["sort_order" => -1]);
// 결과 확인
$children = Menu::where("tenant_id", 1)->where("parent_id", 15385)->orderBy("sort_order")->get();
foreach ($children as $c) {
echo $c->name . " | sort:" . $c->sort_order . PHP_EOL;
}
'
```
**결과**:
```
대시보드 | sort:-1
영업담당자 관리 | sort:0
가망고객 관리 | sort:1
영업실적 관리 | sort:2
```
---
### 15. 영업관리 - 상품관리 메뉴 추가
**문제**: 로컬에는 있는 "영업관리 - 상품관리" 메뉴가 서버에 없음
**작업 내용** (tinker 사용):
```bash
cd /home/webservice/mng
php artisan tinker --execute='
use App\Models\Commons\Menu;
$parentId = 15385;
$m = Menu::create([
"tenant_id" => 1,
"parent_id" => $parentId,
"name" => "상품관리",
"url" => "/sales/products",
"is_active" => true,
"sort_order" => 4,
"hidden" => false,
"icon" => "cube"
]);
echo "Created: " . $m->id;
'
```
**결과**: Menu ID 15400 생성
---
### 16. 상품 데이터 추가 (카테고리 + 상품)
**문제**: 서버에 상품 데이터가 없음 (로컬에만 존재)
**작업 내용** (tinker 사용):
```bash
cd /home/webservice/mng
php artisan tinker --execute='
use Illuminate\Support\Facades\DB;
// 카테고리 추가
$categories = [
["id" => 5, "code" => "MANUFACTURER", "name" => "제조 업체", ...],
["id" => 6, "code" => "CONSTRUCTION", "name" => "공사 업체", ...],
];
foreach ($categories as $cat) {
DB::table("sales_product_categories")->insert($cat);
}
// 상품 추가 (11개)
$products = [
// 제조업 상품 8개 (MFG_BASIC, MFG_QUALITY, MFG_PROCESS_ADD, MFG_AI, MFG_PHOTO, MFG_INVOICE_CARD, MFG_EQUIPMENT, MFG_RND)
// 공사업 상품 3개 (CON_BASIC, CON_AI, CON_PHOTO)
];
foreach ($products as $prod) {
DB::table("sales_products")->insert($prod);
}
'
```
**결과**:
- 카테고리 2개 생성 (제조 업체, 공사 업체)
- 상품 11개 생성 (제조업 8개, 공사업 3개)