# Guide #57 — 정산 모델 재설계 + 파트너 관리 고도화

> **작성일**: 2026-03-25
> **대상 모듈**: `common` (Entity/Enum), `core` (SettlementService), `admin-api`, `partner-api`, `partner-ui`, `scheduler`
> **선행 조건**: Guide #56 완료, 더미 데이터 적용 (`v2-docs/DUMMY_DATA_SETTLEMENT_TEST.sql`)

---

## Part A — 수수료 모델 재설계 (누적 최소값 모델)

### A-0. 설계 변경 요약

**기존 모델 (v0.3)**:
- `deposit_fee_rate`가 최상위에서 고정 → 전체 하위에 동일 적용
- `revenue_share_rate`로 분배율만 조정

**신규 모델 (v0.4)**:
- 시스템은 **총판별 개별 계약** — "내 몫 X%만 보장"
- 총판은 **하위 수수료율을 자유롭게 책정** (가맹점마다 다를 수 있음)
- 각 단계에서 **"내 상위가 부과하는 몫"**만 저장
- `min_fee_rate`는 **누적 최소** (자동 계산 = 부모의 min_fee_rate + parent_fee_rate)

### A-1. 필드 재정의

| 필드 | 대상 | 의미 (v0.4) | 예시 |
|------|------|-------------|------|
| `parent_fee_rate` | 모든 파트너 | **내 부모가 나를 만들 때 정한 부모의 수익률** | 시스템→총판A: 0.2% |
| `min_fee_rate` | 모든 파트너 | **누적 최소** = 부모의 min_fee_rate + parent_fee_rate (자동 계산) | 총판A: 0.2%, 부총판X: 0.7% |
| `max_fee_cap` | 총판/시스템 | **하위 파트너가 설정 가능한 수수료 상한** | 5.0% |
| `deposit_fee_rate` | MERCHANT 필수, DISTRIBUTOR 선택 | **실제 입금 수수료율** (min_fee_rate 이상이어야 함) | 1.0% |

> **핵심**: 총판(DISTRIBUTOR)도 `deposit_fee_rate`를 설정하면 직접 입금 서비스가 가능.
> - `deposit_fee_rate > 0`: 직접 입금 처리 가능 (본인이 가맹점 역할 겸임)
> - `deposit_fee_rate = 0` (기본값): 순수 총판 — 하위 파트너 관리 + 정산만
> - 검증 규칙은 MERCHANT와 동일: `min_fee_rate ≤ deposit_fee_rate ≤ max_fee_cap`
> - 총판의 마진 = `deposit_fee_rate - min_fee_rate` (직접 입금 시)

### A-2. 파트너 생성 시 동작

#### 시스템이 최상위 총판 생성

```
입력: parent_fee_rate (시스템 수익), max_fee_cap (상한)
계산: min_fee_rate = parent_fee_rate  (부모가 시스템이므로)
저장: Partner { parentPartnerId=NULL, parentFeeRate=0.2, minFeeRate=0.2, maxFeeCap=5.0 }
```

#### 총판이 하위 총판 생성

```
총판A (min_fee_rate=0.2%, max_fee_cap=5.0%)가 부총판X 생성
입력: parent_fee_rate=0.5% (총판A의 수익)
계산: min_fee_rate = 부모.min_fee_rate + parent_fee_rate = 0.2 + 0.5 = 0.7%
저장: Partner { parentPartnerId=A.id, parentFeeRate=0.5, minFeeRate=0.7, maxFeeCap=5.0 }
```

#### 총판이 가맹점 생성

```
부총판X (min_fee_rate=0.7%, max_fee_cap=5.0%)가 가맹점M 생성
입력: deposit_fee_rate=1.0%
검증: min_fee_rate(0.7%) ≤ deposit_fee_rate(1.0%) ≤ max_fee_cap(5.0%) ✅
UI 표시: 예상 마진 = 1.0% - 0.7% = 0.3%
저장: Partner { parentPartnerId=X.id, parentFeeRate=0, minFeeRate=0.7, depositFeeRate=1.0 }
```

#### 총판이 본인 deposit_fee_rate 설정 (직접 입금 서비스)

```
총판A (min_fee_rate=0.2%, max_fee_cap=5.0%)가 본인 수수료율 설정
입력: deposit_fee_rate=1.0%
검증: min_fee_rate(0.2%) ≤ deposit_fee_rate(1.0%) ≤ max_fee_cap(5.0%) ✅
저장: Partner.depositFeeRate = 1.0

→ 총판A가 직접 입금 받으면:
  시스템:  0.2% (Partner1의 parent_fee_rate)
  총판A:   0.8% (1.0% - 0.2% = deposit_fee_rate - min_fee_rate)
  합계:    1.0%
```

> **입금 세션 생성 조건**: `partner_type` 체크 대신 `deposit_fee_rate > 0`만 확인.
> DISTRIBUTOR든 MERCHANT든 deposit_fee_rate가 설정되어 있으면 입금 처리 가능.

### A-3. 수수료 분배 계산 (정산 시)

가맹점M에서 100 USDT 입금 (fee_rate=1.0%, fee=1.0 USDT):

```
1. 트리 올라가며 각 단계의 parent_fee_rate 차감:
   ① 부총판X.parent_fee_rate = 0.5%  → 총판A에게 0.50 USDT
   ② 총판A.parent_fee_rate = 0.2%    → 시스템에게 0.20 USDT
   ③ 잔여 = 1.0% - 0.5% - 0.2% = 0.3% → 부총판X 마진 0.30 USDT

2. 하지만 ①에서 총판A에게 준 0.5%에서 시스템 몫(0.2%)을 다시 분리:
   → 총판A 실제 마진 = 0.5% - 0.2% = 0.3% → 0.30 USDT

최종 분배:
  시스템:   0.20 USDT (0.2%)
  총판A:    0.30 USDT (0.3% = 0.5% - 0.2%)
  부총판X:  0.50 USDT (0.5% = 1.0% - 0.5%)
  합계:     1.00 USDT (1.0%)
```

> **분배 방향**: 가맹점에서 위로 올라가며 각 단계의 `parent_fee_rate`를 빼는 방식.
> 최하위 총판(가맹점의 직접 부모)이 가장 큰 잔여분을 가져감.

### A-4. Spring Boot 변경사항

#### A-4-1. `SettlementService.aggregateDailyFees()` 수정

현재 로직: `deposit_fee_rate`와 `revenue_share_rate` 기반 고정 분배
변경: **파트너 트리 순회 + parent_fee_rate 차감 방식**

```java
/**
 * 일별 수수료 집계 — 신규 로직 (v0.4)
 *
 * 1. 전일 SETTLED 입금을 partner_id + currency_id + network_id 그룹으로 집계
 * 2. 각 파트너의 deposit_fee_rate로 총 수수료 계산 (MERCHANT 또는 DISTRIBUTOR)
 * 3. 파트너 트리를 올라가며 각 단계의 parent_fee_rate로 쉐어 분배
 *    - 각 중간 총판: (자기 parent_fee_rate - 부모 parent_fee_rate) × 입금액
 *    - 입금 발생 파트너(가맹점 또는 직접 입금 총판): (deposit_fee_rate - min_fee_rate) × 입금액
 *    - 시스템: 최상위 parent_fee_rate × 입금액
 * 4. settlement_daily_fees에 INSERT (UNREALIZED)
 * 5. settlement_balances UPSERT (unrealized_balance 누적)
 */
```

**핵심 변경 포인트**:
- `share_rate` 필드: 기존 revenue_share_rate → 각 참여자의 실제 마진 비율 저장
- `source_partner_id`: 입금이 발생한 가맹점의 직접 부모 총판 → **입금 발생 가맹점 자체**로 변경 고려
- 트리 순회 시 `partnerRepository.findOne(parentPartnerId)` 재귀 호출 필요

#### A-4-2. `PartnerManagementService.createPartner()` / `PartnerSubMgmtService.createSubPartner()` 수정

```java
// 하위 총판 생성 시
newPartner.setMinFeeRate(parent.getMinFeeRate().add(request.getParentFeeRate()));
newPartner.setMaxFeeCap(parent.getMaxFeeCap());  // 상속

// 가맹점 생성 시 (deposit_fee_rate 필수)
BigDecimal minFee = parent.getMinFeeRate();
BigDecimal maxFee = parent.getMaxFeeCap();
BigDecimal depositFee = request.getDepositFeeRate();
if (depositFee.compareTo(minFee) < 0 || depositFee.compareTo(maxFee) > 0) {
    throw new BadRequestException("수수료율은 " + minFee + "% ~ " + maxFee + "% 범위여야 합니다.");
}

// 총판의 deposit_fee_rate 설정/변경 시 (선택사항, 직접 입금 서비스용)
// — 총판 생성 시 또는 이후 별도 API로 설정 가능
if (request.getDepositFeeRate() != null && request.getDepositFeeRate().compareTo(BigDecimal.ZERO) > 0) {
    BigDecimal selfMin = newPartner.getMinFeeRate();
    BigDecimal selfMax = newPartner.getMaxFeeCap();
    if (request.getDepositFeeRate().compareTo(selfMin) < 0 || request.getDepositFeeRate().compareTo(selfMax) > 0) {
        throw new BadRequestException("수수료율은 " + selfMin + "% ~ " + selfMax + "% 범위여야 합니다.");
    }
    newPartner.setDepositFeeRate(request.getDepositFeeRate());
}
```

#### A-4-3. 시스템 인상 시 캐스케이드

시스템이 특정 총판의 `parent_fee_rate`를 인상하면:
- 해당 총판의 `min_fee_rate` 재계산
- **하위 전체 트리의 `min_fee_rate` 재계산** (재귀)
- 기존 가맹점의 `deposit_fee_rate < 새 min_fee_rate`인 경우 → 경고 리스트 반환 (자동 변경 안 함)

```java
// admin-api: PartnerManagementService.updateFees()에 추가
List<Partner> affectedMerchants = findMerchantsWithInsufficientFeeRate(partnerId, newMinFeeRate);
// 경고만 반환, 자동 변경하지 않음 — 총판이 직접 조정
```

### A-5. DDL 변경

기존 컬럼 그대로 사용, 의미만 재정의. **DDL 변경 없음**.

| 컬럼 | 타입 | 변경 | 비고 |
|------|------|------|------|
| `parent_fee_rate` | DECIMAL(10,6) | 의미 변경 | 부모의 수익률 (생성 시 부모가 입력) |
| `min_fee_rate` | DECIMAL(10,6) | 의미 변경 | 누적 최소 (자동 계산) |
| `max_fee_cap` | DECIMAL(10,6) | 유지 | 하위 수수료 상한 |
| `deposit_fee_rate` | DECIMAL(10,6) | 의미 변경 | MERCHANT 필수, DISTRIBUTOR 선택 (0이면 직접 입금 불가) |

> `revenue_share_rate` 필드 불필요 — DDL에 없으므로 변경 없음.
> 입금 세션 생성 시 `partner_type` 체크 대신 `deposit_fee_rate > 0` 조건으로 변경.

### A-6. 정산 스케줄러 구현

#### Daily Fee Aggregation Job (신규)

```java
@Component
public class SettlementDailyAggregationJob {

    @Scheduled(cron = "0 30 0 * * *", zone = "Asia/Seoul")  // 매일 00:30 KST
    public void aggregatePreviousDayFees() {
        LocalDate yesterday = LocalDate.now(ZoneId.of("Asia/Seoul")).minusDays(1);
        log.info("일별 수수료 집계 시작: {}", yesterday);

        try {
            int count = settlementService.aggregateDailyFees(yesterday);
            log.info("일별 수수료 집계 완료: {} 건", count);
        } catch (Exception e) {
            log.error("일별 수수료 집계 실패: {}", yesterday, e);
            // 알림 발송 (Telegram 등)
        }
    }
}
```

**파일**: `scheduler/src/main/java/com/cryptoments/scheduler/job/SettlementDailyAggregationJob.java`

---

## Part B — 정산 UI 개선 (partner-ui)

### B-1. 정산 메뉴 총판 전용으로 이동

**현재**: `정산` 메뉴가 독립 그룹 (모든 파트너 노출)
**변경**: `총판` 그룹 하위로 이동 (`distributorOnly: true`)

```typescript
// constants.ts — MENU_ITEMS 변경
{
  label: '총판', icon: Users, distributorOnly: true,
  children: [
    { label: '파트너 관리', path: '/partner/sub-partners' },
    { label: '거래 현황', path: '/partner/sub-partners/overview' },
    { label: '정산 현황', path: '/partner/settlement' },         // ← 이동
    { label: '매출 리포트', path: '/partner/reports/revenue' },
    { label: '수수료 수익', path: '/partner/reports/commission' },
    { label: '성과 비교', path: '/partner/reports/performance' },
  ],
},
```

> 기존 독립 `정산` 메뉴 항목 제거.

### B-2. 정산 잔액 화면 — 출금가능 음수값 방어

**파일**: `BalanceView.vue`

```typescript
// withdrawableBalance가 음수일 때 0 표시 + 경고 스타일
function safeWithdrawable(b: SettlementBalance): number {
  return Math.max(b.withdrawableBalance ?? 0, 0)
}
```

```html
<!-- 출금 버튼: 0 이하면 비활성 -->
<Button :disabled="safeWithdrawable(b) <= 0">쉐어 출금 요청</Button>
```

### B-3. 출금 이력 탭 — SETTLEMENT_WITHDRAW 고정 필터

**현재**: "정산 출금 이력은 출금 내역에서 유형 SETTLEMENT_WITHDRAW로 확인하세요" 메시지만 표시
**변경**: 출금 API를 `withdrawal_type=SETTLEMENT_WITHDRAW` 필터로 직접 호출하여 데이터 표시

**파일**: `SettlementView.vue` (탭 구성) 또는 별도 `SettlementWithdrawalsView.vue`

```typescript
// 정산 출금 내역 조회
async function fetchSettlementWithdrawals() {
  const result = await withdrawalService.getWithdrawals({
    ...filters,
    withdrawalType: 'SETTLEMENT_WITHDRAW',
  })
  data.value = result
}
```

```typescript
// columns
const columns = [
  { key: 'createdAt', label: '요청일' },
  { key: 'networkSymbol', label: '네트워크' },
  { key: 'currencySymbol', label: '통화' },
  { key: 'amount', label: '금액', class: 'text-right' },
  { key: 'toAddress', label: '수신 주소' },
  { key: 'txHash', label: 'TX Hash' },
  { key: 'status', label: '상태' },
]
```

### B-4. 백엔드 — 출금 목록 withdrawalType 필터 추가

**현재**: `PartnerWithdrawalController.getWithdrawals()`에 withdrawalType 필터 없음
**변경**: 쿼리 파라미터 추가

```java
// PartnerWithdrawalController
@GetMapping
public XPage<WithdrawalResponse> getWithdrawals(
    XPagination pagination,
    @RequestParam(required = false) String status,
    @RequestParam(required = false) String withdrawalType,  // ← 추가
    @RequestParam(required = false) String from,
    @RequestParam(required = false) String to) {
    // ...
}
```

```sql
-- PartnerWithdrawalMapper: searchWithdrawals에 조건 추가
<if test="withdrawalType != null">AND w.withdrawal_type = #{withdrawalType}</if>
```

---

## Part C — 파트너 관리 고도화 (partner-ui)

### C-1. 파트너 목록 — 정보 보강

**현재 컬럼**: partnerCode, partnerName, partnerType, status, contactEmail, createdAt
**변경 컬럼**:

```typescript
const columns: Column[] = [
  { key: 'partnerCode', label: '코드', class: 'font-mono text-xs' },
  { key: 'partnerName', label: '파트너명' },
  { key: 'partnerType', label: '유형' },
  { key: 'parentPartnerName', label: '상위 파트너' },        // ← 추가
  { key: 'depositFeeRate', label: '수수료율', class: 'text-right' }, // ← 추가 (MERCHANT만)
  { key: 'marginRate', label: '마진', class: 'text-right' },  // ← 추가 (계산값)
  { key: 'status', label: '상태' },
  { key: 'createdAt', label: '등록일' },
]
```

**marginRate 계산** (프론트):
```typescript
function getMarginRate(partner: SubPartner): string {
  if (partner.partnerType === 'MERCHANT' && partner.depositFeeRate && partner.minFeeRate) {
    return (partner.depositFeeRate - partner.minFeeRate).toFixed(3) + '%'
  }
  return '-'
}
```

### C-2. 파트너 목록 — 계층 트리 표시

**현재**: 단순 flat 목록
**변경**: 상위 파트너별 그룹핑 또는 트리 들여쓰기 표시

**방법 A — 들여쓰기 (간단)**:
```typescript
// 파트너 목록을 트리 순서로 정렬 후, depth에 따라 들여쓰기
function getTreeDepth(partner: SubPartner, allPartners: SubPartner[]): number {
  let depth = 0
  let current = partner
  while (current.parentPartnerId) {
    current = allPartners.find(p => p.id === current.parentPartnerId)!
    depth++
  }
  return depth
}
```

```html
<!-- 파트너명 앞에 depth 만큼 들여쓰기 -->
<template #cell-partnerName="{ row }">
  <span :style="{ paddingLeft: getTreeDepth(row) * 20 + 'px' }">
    {{ row.partnerName }}
  </span>
</template>
```

**방법 B — 아코디언 트리 (권장)**:
- 상위 총판을 클릭하면 하위 파트너가 펼쳐지는 구조
- 별도 API 불필요 — 기존 목록 데이터에서 클라이언트 사이드 트리 구성

### C-3. 백엔드 — 파트너 목록 응답에 필드 추가

**PartnerSubMgmtService / PartnerSubMgmtMapper**:

현재 `/api/partner/sub-partners`가 직속 하위만 조회 → **하위 전체 트리 조회**로 변경 필요.

```java
// PartnerSubMgmtMapper: 기존
@Select("SELECT p.* FROM partners p WHERE p.parent_partner_id = #{distributorId}")

// 변경: 재귀 하위 전체 (또는 2~3 depth까지)
@Select("<script>" +
    "WITH RECURSIVE partner_tree AS (" +
    "  SELECT id, partner_code, name, partner_type, parent_partner_id, " +
    "    deposit_fee_rate, min_fee_rate, parent_fee_rate, status, contact_email, created_at, " +
    "    1 AS depth " +
    "  FROM partners WHERE parent_partner_id = #{distributorId} " +
    "  UNION ALL " +
    "  SELECT p.id, p.partner_code, p.name, p.partner_type, p.parent_partner_id, " +
    "    p.deposit_fee_rate, p.min_fee_rate, p.parent_fee_rate, p.status, p.contact_email, p.created_at, " +
    "    pt.depth + 1 " +
    "  FROM partners p INNER JOIN partner_tree pt ON p.parent_partner_id = pt.id " +
    "  WHERE pt.depth < 5 " +  // 최대 5 depth
    ") SELECT * FROM partner_tree " +
    "<where>" +
    "  <if test='search != null'>AND (partner_code LIKE CONCAT('%',#{search},'%') OR name LIKE CONCAT('%',#{search},'%'))</if>" +
    "  <if test='status != null'>AND status = #{status}</if>" +
    "</where>" +
    "ORDER BY depth, id" +
    "</script>")
XPage<SubPartnerListResponse> searchSubPartnerTree(XPagination pagination,
    @Param("distributorId") Long distributorId,
    @Param("search") String search,
    @Param("status") String status,
    Class<?> cls);
```

**SubPartnerListResponse DTO (신규/수정)**:

```java
@Getter @Setter @Builder
@NoArgsConstructor @AllArgsConstructor
public class SubPartnerListResponse {
    /** PK */
    private Long id;
    /** 파트너 코드 */
    private String partnerCode;
    /** 파트너명 */
    private String name;
    /** 유형 */
    private String partnerType;
    /** 상태 */
    private String status;
    /** 상위 파트너 ID */
    private Long parentPartnerId;
    /** 상위 파트너명 (JOIN) */
    private String parentPartnerName;
    /** 수수료율 (MERCHANT) */
    private BigDecimal depositFeeRate;
    /** 누적 최소 수수료 */
    private BigDecimal minFeeRate;
    /** 부모 수수료 */
    private BigDecimal parentFeeRate;
    /** 트리 depth */
    private Integer depth;
    /** 이메일 */
    private String contactEmail;
    /** 등록일 */
    private LocalDateTime createdAt;
}
```

### C-4. 파트너 상세 화면 (신규)

**현재**: 파트너 목록에서 클릭해도 상세 화면 없음
**변경**: `/partner/sub-partners/:id` 상세 화면 추가

**파일**: `partner-ui/src/views/partner/subpartners/SubPartnerDetailView.vue` (신규)

#### 상세 화면 구성

```
┌──────────────────────────────────────────────────────────────┐
│  ← 파트너 관리                                                │
│                                                              │
│  가맹점A1 (MCH_A101)                          [상태: 활성]    │
│                                                              │
│  ┌─ 기본 정보 ─────────────────────────────────────────────┐ │
│  │ 유형: 가맹점 | 상위: 알파총판 | 등록일: 2026-03-25       │ │
│  │ 이메일: a1@test.com | 전화: -                            │ │
│  └─────────────────────────────────────────────────────────┘ │
│                                                              │
│  ┌─ 수수료 정보 ───────────────────────────────────────────┐ │
│  │ 수수료율: 1.0% | 최소: 0.7% | 상한: 5.0% | 마진: 0.3%  │ │
│  └─────────────────────────────────────────────────────────┘ │
│                                                              │
│  ┌─ 하위 파트너 트리 (DISTRIBUTOR만 표시) ──────────────────┐ │
│  │ └ 가맹점A1 (1.0%, 마진 0.3%)                            │ │
│  │ └ 가맹점A2 (1.2%, 마진 0.5%)                            │ │
│  │ └ 가맹점A3 (0.8%, 마진 0.1%)                            │ │
│  └─────────────────────────────────────────────────────────┘ │
│                                                              │
│  ┌─ 거래 요약 (최근 30일) ─────────────────────────────────┐ │
│  │ 총 입금: 4,750 USDT | 총 수수료: 47.5 USDT              │ │
│  │ 총 출금: 0 USDT                                          │ │
│  └─────────────────────────────────────────────────────────┘ │
│                                                              │
│  [임시 비밀번호 발급]  [계정 정지]  [계정 활성화]            │
└──────────────────────────────────────────────────────────────┘
```

#### 백엔드 엔드포인트

기존 `GET /api/partner/sub-partners/{partnerId}` 활용 — 응답에 거래 요약 추가:

```java
@Getter @Setter @Builder
@NoArgsConstructor @AllArgsConstructor
public class SubPartnerDetailResponse {
    // 기본 정보
    /** PK */
    private Long id;
    /** 파트너 코드 */
    private String partnerCode;
    /** 파트너명 */
    private String name;
    /** 유형 */
    private String partnerType;
    /** 상태 */
    private String status;
    /** 상위 파트너 ID */
    private Long parentPartnerId;
    /** 상위 파트너명 */
    private String parentPartnerName;

    // 수수료 정보
    /** 수수료율 (MERCHANT) */
    private BigDecimal depositFeeRate;
    /** 부모 수수료 */
    private BigDecimal parentFeeRate;
    /** 누적 최소 */
    private BigDecimal minFeeRate;
    /** 상한 */
    private BigDecimal maxFeeCap;

    // 연락처
    /** 이메일 */
    private String contactEmail;
    /** 전화번호 */
    private String contactPhone;
    /** 등록일 */
    private LocalDateTime createdAt;

    // 거래 요약 (최근 30일)
    /** 총 입금액 (USD) */
    private BigDecimal totalDepositUsd;
    /** 총 수수료 (USD) */
    private BigDecimal totalFeeUsd;
    /** 총 출금액 (USD) */
    private BigDecimal totalWithdrawalUsd;

    // 하위 파트너 (DISTRIBUTOR만)
    /** 하위 파트너 수 */
    private Integer subPartnerCount;
    /** 하위 파트너 목록 (1 depth만) */
    private List<SubPartnerSummary> subPartners;
}
```

### C-5. 파트너 관리 액션 기능

#### C-5-1. 임시 비밀번호 발급

**엔드포인트**: `POST /api/partner/sub-partners/{partnerId}/reset-password`

```java
// PartnerSubPartnerController 추가
@PostMapping("/{partnerId}/reset-password")
public Map<String, String> resetPassword(@PathVariable Long partnerId) {
    requireDistributor();
    String tempPassword = service.resetPassword(getSession().getPartnerId(), partnerId);
    return Map.of("tempPassword", tempPassword);
}
```

```java
// PartnerSubMgmtService
public String resetPassword(Long distributorId, Long subPartnerId) {
    Partner sub = getAndValidateOwnership(distributorId, subPartnerId);
    String tempPw = generateTempPassword(12);  // 12자리 랜덤
    sub.setPasswordHash(passwordEncoder.encode(tempPw));
    partnerRepository.modify(sub);
    return tempPw;
}
```

**UI**: 버튼 클릭 → 확인 다이얼로그 → 임시 비밀번호 표시 (복사 버튼 포함)

#### C-5-2. 계정 정지 / 활성화

**엔드포인트**: `PATCH /api/partner/sub-partners/{partnerId}/status`

```java
// PartnerSubPartnerController 추가
@PatchMapping("/{partnerId}/status")
public Partner changeStatus(@PathVariable Long partnerId,
                            @RequestBody StatusChangeRequest request) {
    requireDistributor();
    return service.changeSubPartnerStatus(getSession().getPartnerId(), partnerId, request);
}
```

```java
@Getter @Setter
public class StatusChangeRequest {
    /** 변경할 상태 (ACTIVE / SUSPENDED) */
    private String status;
    /** 정지 사유 (SUSPENDED 시 필수) */
    private String reason;
}
```

```java
// PartnerSubMgmtService
public Partner changeSubPartnerStatus(Long distributorId, Long subPartnerId, StatusChangeRequest req) {
    Partner sub = getAndValidateOwnership(distributorId, subPartnerId);

    // 총판은 ACTIVE ↔ SUSPENDED만 가능 (TERMINATED는 Admin 전용)
    if ("SUSPENDED".equals(req.getStatus())) {
        if (!"ACTIVE".equals(sub.getStatus().name())) {
            throw new BadRequestException("활성 상태의 파트너만 정지할 수 있습니다.");
        }
        sub.setStatus(PartnerStatus.SUSPENDED);
        sub.setSuspendedAt(LocalDateTime.now());
        sub.setSuspendReason(req.getReason());
    } else if ("ACTIVE".equals(req.getStatus())) {
        if (!"SUSPENDED".equals(sub.getStatus().name())) {
            throw new BadRequestException("정지 상태의 파트너만 활성화할 수 있습니다.");
        }
        sub.setStatus(PartnerStatus.ACTIVE);
        sub.setSuspendedAt(null);
        sub.setSuspendReason(null);
    } else {
        throw new BadRequestException("허용되지 않는 상태입니다. (ACTIVE 또는 SUSPENDED만 가능)");
    }

    partnerRepository.modify(sub);
    return sub;
}
```

**UI**: 상태에 따라 버튼 동적 표시
- 활성 → `[계정 정지]` 버튼 (사유 입력 다이얼로그)
- 정지 → `[계정 활성화]` 버튼 (확인 다이얼로그)

### C-6. 파트너 등록 모달 — 수수료 입력 UX 개선

**현재**: `depositFeeRate` 하나만 입력
**변경**: min/max 범위 표시 + 실시간 마진 계산

```html
<!-- SubPartnersView.vue 등록 모달 내 수수료 입력 영역 -->
<div class="space-y-2">
  <!-- DISTRIBUTOR 선택 시 -->
  <template v-if="regForm.partnerType === 'DISTRIBUTOR'">
    <label class="text-sm font-medium">내 수익률 (%)</label>
    <Input v-model="regForm.parentFeeRate" type="number" step="0.001" min="0.001" />
    <p class="text-xs text-muted-foreground">
      하위 파트너의 누적 최소 수수료: {{ (currentMinFeeRate + Number(regForm.parentFeeRate || 0)).toFixed(3) }}%
    </p>
  </template>

  <!-- MERCHANT 선택 시 -->
  <template v-else>
    <label class="text-sm font-medium">수수료율 (%)</label>
    <Input v-model="regForm.depositFeeRate" type="number" step="0.001"
      :min="currentMinFeeRate" :max="currentMaxFeeCap" />
    <p class="text-xs text-muted-foreground">
      범위: {{ currentMinFeeRate }}% ~ {{ currentMaxFeeCap }}%
    </p>
    <p v-if="Number(regForm.depositFeeRate) >= currentMinFeeRate"
       class="text-xs text-green-600 font-medium">
      예상 마진: {{ (Number(regForm.depositFeeRate) - currentMinFeeRate).toFixed(3) }}%
    </p>
    <p v-else class="text-xs text-red-600 font-medium">
      최소 수수료율 미달
    </p>
  </template>
</div>
```

```typescript
// 현재 로그인한 총판의 min_fee_rate, max_fee_cap
const currentMinFeeRate = ref(0)
const currentMaxFeeCap = ref(5)

onMounted(async () => {
  // 내 파트너 정보에서 min/max 로드
  const profile = await accountService.getProfile()
  currentMinFeeRate.value = profile.minFeeRate ?? 0
  currentMaxFeeCap.value = profile.maxFeeCap ?? 5
})
```

### C-7. 라우터 추가

```typescript
// router/index.ts 추가
{
  path: '/partner/sub-partners/:id',
  name: 'SubPartnerDetail',
  component: () => import('@/views/partner/subpartners/SubPartnerDetailView.vue'),
  meta: { requiresAuth: true, distributorOnly: true },
},
```

---

## Part D — 작업 순서

### Phase 1: 백엔드 — 수수료 모델 변경 (IntelliJ)

| # | 작업 | 파일 |
|---|------|------|
| D1 | `SettlementService.aggregateDailyFees()` — 트리 순회 분배 로직으로 변경 | `core/settlement/SettlementService.java` |
| D2 | `PartnerSubMgmtService.createSubPartner()` — min_fee_rate 자동 계산 | `partner-api/service/PartnerSubMgmtService.java` |
| D3 | `PartnerManagementService.createPartner()` — 동일 로직 적용 | `admin-api/service/PartnerManagementService.java` |
| D4 | `PartnerManagementService.updateFees()` — 캐스케이드 재계산 + 경고 | `admin-api/service/PartnerManagementService.java` |
| D5 | 파트너 목록 재귀 쿼리 (CTE) | `partner-api/mapper/PartnerSubMgmtMapper.java` |
| D6 | `SubPartnerListResponse` DTO (parentPartnerName, depth 추가) | `partner-api/dto/response/SubPartnerListResponse.java` |
| D7 | `SubPartnerDetailResponse` DTO (거래 요약, 하위 목록) | `partner-api/dto/response/SubPartnerDetailResponse.java` |
| D8 | 상세 조회 서비스 수정 | `partner-api/service/PartnerSubMgmtService.java` |
| D9 | 임시 비밀번호 + 상태 변경 엔드포인트 | `partner-api/controller/PartnerSubPartnerController.java` |
| D10 | 출금 목록 withdrawalType 필터 | `partner-api/controller/PartnerWithdrawalController.java` |
| D11 | Daily Fee Aggregation Job | `scheduler/job/SettlementDailyAggregationJob.java` |
| D12 | 컴파일 확인 | `./gradlew :core:compileJava :partner-api:compileJava :scheduler:compileJava` |

### Phase 2: 프론트엔드 — partner-ui (VS Code)

| # | 작업 | 파일 |
|---|------|------|
| E1 | 사이드바: 정산 → 총판 그룹으로 이동 | `constants.ts` |
| E2 | 파트너 목록: 컬럼 추가 (상위, 수수료, 마진) + 트리 들여쓰기 | `SubPartnersView.vue` |
| E3 | 파트너 등록 모달: 수수료 min/max + 마진 계산 | `SubPartnersView.vue` |
| E4 | 파트너 상세 화면 (신규) | `SubPartnerDetailView.vue` (신규) |
| E5 | 상세 화면 라우터 등록 | `router/index.ts` |
| E6 | 정산 잔액: 음수 방어 | `BalanceView.vue` |
| E7 | 정산 출금이력: SETTLEMENT_WITHDRAW 필터 적용 | `SettlementView.vue` 또는 신규 뷰 |

### Phase 3: 검증

| # | 작업 |
|---|------|
| F1 | 파트너1(test) 로그인 → 총판 그룹에 정산 메뉴 확인 |
| F2 | 파트너 관리 목록 → 상위 파트너, 수수료율, 마진, 트리 표시 확인 |
| F3 | 파트너 등록 → MERCHANT 생성 시 min/max 범위 + 마진 계산 확인 |
| F4 | 파트너 등록 → DISTRIBUTOR 생성 시 누적 최소 표시 확인 |
| F5 | 파트너 상세 → 수수료 정보, 거래 요약, 하위 트리 확인 |
| F6 | 임시 비밀번호 발급 → 새 비밀번호로 로그인 가능 확인 |
| F7 | 계정 정지/활성화 → 상태 전환 정상 동작 확인 |
| F8 | 정산 잔액 → 음수 withdrawableBalance가 0으로 표시 확인 |
| F9 | 정산 출금이력 탭 → SETTLEMENT_WITHDRAW 데이터만 표시 확인 |
| F10 | 더미 데이터로 정산 현황 데이터 표시 확인 (3단계 계층) |

---

## 체크리스트

### Part A — 수수료 모델

- [ ] **D1** — SettlementService.aggregateDailyFees() 트리 순회 분배 로직
- [ ] **D2** — PartnerSubMgmtService.createSubPartner() min_fee_rate 자동 계산
- [ ] **D3** — PartnerManagementService.createPartner() 동일 적용
- [ ] **D4** — PartnerManagementService.updateFees() 캐스케이드 + 경고
- [ ] **D11** — SettlementDailyAggregationJob (매일 00:30 KST)

### Part B — 정산 UI

- [ ] **E1** — 정산 메뉴 → 총판 그룹 이동
- [ ] **E6** — 정산 잔액 음수 방어
- [ ] **E7** — 정산 출금이력 SETTLEMENT_WITHDRAW 필터
- [ ] **D10** — 출금 API withdrawalType 필터 추가

### Part C — 파트너 관리

- [ ] **D5** — 재귀 CTE 쿼리
- [ ] **D6** — SubPartnerListResponse DTO
- [ ] **D7** — SubPartnerDetailResponse DTO
- [ ] **D8** — 상세 조회 서비스
- [ ] **D9** — 임시 비밀번호 + 상태 변경 엔드포인트
- [ ] **E2** — 파트너 목록 컬럼 + 트리 표시
- [ ] **E3** — 등록 모달 수수료 UX
- [ ] **E4** — 파트너 상세 화면 (신규)
- [ ] **E5** — 라우터 등록

### 검증

- [ ] **D12** — 전체 컴파일 성공
- [ ] **F1~F10** — UI 동작 확인
