# 정산 집계 스킵 버그 — `computeMinFeeRate` 검증 산식 교정 지침서

작성: 2026-08-24 / 대상: `core` (SettlementService)

## 증상 (실측)

`SettlementDailyAggregationJob`(매일 00:30)이 다수 파트너를 **조용히 건너뛰고 있다**.

```
Aug 23 00:30  minFeeRate 불일치 감지! partnerId=45, stored=0.200000, computed=0.        정산 건너뜀.
Aug 23 00:30  minFeeRate 불일치 감지! partnerId=54, stored=0.400000, computed=0.200000. 정산 건너뜀.
Aug 24 00:30  minFeeRate 불일치 감지! partnerId=36, stored=0.200000, computed=0.        정산 건너뜀.
```

**누락 규모 (2026-08-24 기준, 8/9 ~ 8/24 누적 약 380.9 USDT)**

| 파트너 | 통화 | 누락 일수 | 기간 | 미배분 |
|---|---|---|---|---|
| 45 샤크 | USDT-TRON | 16 | 8/09~8/24 | 234.97 |
| 36 피터 | USDT-TRON | 8 | 8/10~8/24 | 109.90 |
| 44 FOX_LION | USDT-TRON | 1 | 8/24 | 18.37 |
| 54 보물섬 | USDT-BSC | 1 | 8/22 | 15.67 |
| 58 파티 · 65 범퍼카 · 49 핀유 | | | | 1.75 |

**돈이 사라진 건 아니다.** 원장 FEE 로 파트너에게서 이미 징수했다(파트너 잔액은 정확).
다만 `settlement_daily_fees` 행이 없어 **시스템 몫/상위 파트너 몫으로 배분되지 않았고**,
`SettlementRealizationJob` 은 이 테이블의 UNREALIZED 를 보므로 **실현(스윕)도 영원히 안 된다**.
결과적으로 파트너 MASTER 지갑에 "주인 없는 USDT" 로 남는다.

## 근본 원인 — 검증 함수가 자기 자신의 `parent_fee_rate` 를 빼먹는다

`SettlementService.computeMinFeeRate()`:

```java
private BigDecimal computeMinFeeRate(Partner partner) {
    BigDecimal computed = BigDecimal.ZERO;
    Partner current = partner;
    while (current.getParentPartnerId() != null) {   // ← 자기 자신을 건너뛰고
        current = partnerRepository.findOne(current.getParentPartnerId());
        if (current == null) break;
        computed = computed.add(current.getParentFeeRate());   // 부모부터 누적
    }
    return computed;
}
```

실제 파트너 값으로 대조:

| 파트너 | 자기 `parent_fee_rate` | 상위 체인 | 올바른 합 | 현재 코드 | `min_fee_rate`(DB) | 결과 |
|---|---|---|---|---|---|---|
| 45 샤크 (최상위) | 0.2 → SYSTEM | — | **0.2** | **0** | 0.2 | 스킵 |
| 36 피터 (최상위) | 0.2 → SYSTEM | — | **0.2** | **0** | 0.2 | 스킵 |
| 54 보물섬 | 0.2 → 51 | 51의 0.2 → SYSTEM | **0.4** | **0.2** | 0.4 | 스킵 |
| 65 범퍼카 | 0.2 → 51 | 51의 0.2 | **0.4** | **0.2** | 0.4 | 스킵 |
| 44 FOX_LION | **0** | 43의 0.2 | 0.2 | 0.2 | 0.2 | 통과 |

- **자기 `parent_fee_rate` 가 0 인 파트너만 우연히 통과**하고 있었다.
- **최상위 파트너는 루프에 진입조차 못 해 항상 `computed = 0`** 이다. 최상위의 `min_fee_rate`
  는 "SYSTEM 에 줘야 하는 몫"인데 현재 함수로는 셀 방법이 없다.
- ⚠️ **DB `min_fee_rate` 값은 정확하다. 데이터를 건드리지 말 것.** 틀린 건 검증 함수다.
- 바로 아래 **배분 루프(`while (current != null && remainingRate > 0)`)는 이미 자기
  `parent_fee_rate` 부터 센다.** 즉 배분 로직은 맞는데 검증만 다른 규칙을 써서 막고 있다.
  **두 규칙을 일치시키는 것이 이 수정의 본질이다.**

## 수정 A — `computeMinFeeRate` 교정 (필수)

```java
/**
 * 파트너가 상위 체인 전체에 지불해야 하는 요율 합 — 자기 자신의 parent_fee_rate 부터 누적한다.
 *
 * <p>⚠️ 기존 구현은 부모부터 누적해 <b>자기 몫이 빠졌고</b>, 최상위 파트너는 루프에 진입조차
 * 못 해 항상 0 을 반환했다. 그 결과 min_fee_rate 검증이 상시 실패해 해당 파트너의 일별 정산
 * 집계가 통째로 스킵됐다(2026-08-09 ~ 08-24, 약 380.9 USDT 미배분).
 * 아래 배분 루프가 쓰는 규칙(자기 parent_fee_rate 부터)과 반드시 일치해야 한다.
 */
private BigDecimal computeMinFeeRate(Partner partner) {
    BigDecimal computed = BigDecimal.ZERO;
    Partner current = partner;
    while (current != null) {
        computed = computed.add(current.getParentFeeRate() != null
                ? current.getParentFeeRate() : BigDecimal.ZERO);
        if (current.getParentPartnerId() == null) break;
        current = partnerRepository.findOne(current.getParentPartnerId());
    }
    return computed;
}
```

- 순환 참조 방어를 넣어라(방문 ID Set 또는 최대 깊이). 현재도 무한루프 가능성이 있다.
- 위 표의 5개 파트너가 전부 일치(통과)하는지 **단위 테스트로 고정**하라.

## 수정 B — 스킵을 조용히 넘기지 말 것 (권장)

현재는 `log.error` 한 줄 남기고 `continue` 한다. 16일간 아무도 몰랐다.
집계 잡 종료 시 **스킵 건수/파트너 목록을 요약 로그로 남기고**, 1건 이상이면
`notificationService.sendSystemAlert` 로 관리자 알림을 발송하라(기존 알림 헬퍼 사용).

## 하지 말 것

- `partners.min_fee_rate` 데이터를 코드에 맞춰 바꾸는 것 — **데이터가 맞고 코드가 틀렸다**
- 검증 자체를 제거하는 것 — 진짜 트리 불일치를 잡는 안전장치다
- **이 지침서 범위에서 재집계(백필)를 수행하는 것** — 아래 참조

## 재집계(백필)는 이 작업 범위가 아니다

누락분 380.9 USDT 중 상당액이 `PARTNER_CHARGE`(파트너 자기 충전) 기반이고,
**그 유형에 수수료를 물리는 것이 맞는지 정책 결정이 진행 중**이다.

| 파트너 | USER_DEPOSIT | PARTNER_CHARGE |
|---|---|---|
| 45 샤크 | 232.95 | 2.89 |
| 36 피터 | 0.37 | **109.90** |
| 44 FOX_LION | 105.53 | 16.29 |
| 54 보물섬 | — | 15.68 |

정책이 "안 받는다" 로 정해지면 해당분은 재집계가 아니라 **환급** 대상이 된다. 먼저 배분하면
나중에 역산 회수해야 하므로, **코드 수정만 먼저 배포**하고 백필은 정책 확정 후 별건으로 한다.

⚠️ 백필 시 주의(다음 작업자용): 이미 집계된 (날짜×파트너×통화) 를 다시 돌리면 **중복 계상**된다.
누락 조합만 골라 실행해야 한다. 또한 수수료 환급분은 **음수 `FEE` 로 원 징수일에 귀속**되어야
집계 재원에서 상계된다(§69 참조).

## 코딩 규칙

- Java 17. `@Data` 금지, Lombok `@Getter @Builder` 유지
- 기존 로그 포맷/컨벤션 유지
- MyBatis `<script>` 내 `<` 직접 사용 금지 (이번 수정엔 SQL 변경 없음)

## 완료 기준

1. `./gradlew :core:compileJava` 통과
2. 위 표 5개 파트너(45·36·54·65·44)에 대해 `computeMinFeeRate` 결과가 DB `min_fee_rate` 와
   일치함을 **단위 테스트로 검증**
3. 순환 참조 시 무한루프하지 않음
4. 스킵 발생 시 요약 로그 + 관리자 알림 (수정 B)
5. 배분 루프·실현 로직에 회귀 없음

## 커밋

`cryptoments` 레포에서 **커밋만** 하고 push 하지 마라(오케스트레이터 리뷰 후 push).
⚠️ 작업 트리에 다른 워크스트림 편집이 섞여 있을 수 있다. **core 변경분만** 커밋하라.
