# HQ 콘솔 Phase 2 — 수익 대시보드 · 분해 · 대사 (백엔드)

> 설계 원본: `HQ_CONSOLE_DESIGN.md` §2 · §3 · §4-A · §4-B · 부록 A
> 선행: Phase 1.5 인증 (`HQ_PHASE1_5_AUTH_GUIDE.md`) — **완료**
> 대상 모듈: **`partner-api`**
> 범위: **백엔드 API 6개.** UI 는 다음 증분.

---

## 0. 결정 사항 (오너 승인 2026-08-24)

| # | 결정 |
|---|---|
| **스냅샷 테이블 `hq_group_daily_stats` 를 만들지 않는다** | 전부 온디맨드 SQL. 근거: 부록 A-1 실측 — 그룹 최대 10명, 대부분 1~4명. 스냅샷은 리뷰 R1/R2 에서 grain 오설계로 지적됨 |
| **DDL 변경 없음** | 이 Phase 는 조회 전용 |
| G1 산식 | **집계 기준일 절단** 필수 (부록 A-5) |

---

## 1. 실측 기준값 — 구현 후 이 숫자가 나와야 한다

**qndk11(51) 그룹, 전체 기간** (2026-08-24 운영 DB 실측):

| 항목 | 값 |
|---|---|
| 내 수익 (HQ) | **9.282311** |
| 시스템 배분 | **8.720778** |
| 분배 총액 | **18.003089** |
| G1 갭 (절단 후) | **0.000000** |
| 실현 | **0건 / 0.000000** ⚠️ |
| 미실현 | 8.668311 (currency 1 / network 1) + 0.614000 (currency 3 / network 3) |
| 누적 인출 | 0.000000 |

⚠️ **실현이 0 인 것은 정상이다** — `SettlementService` 의 최소 실현 금액 **10 USD 가드**가
`source_partner:currency:network` **그룹 단위**로 걸려 있고, 최대 그룹이 8.668 이라 넘지 못한다.
화면은 **"미실현 9.28 (실현 기준 10 USD 미달)"** 처럼 **기준을 반드시 병기**한다.
병기하지 않으면 "곧 실현될 것"으로 오인된다(`domains/settlement/README.md` 가 명시한 함정).

⚠️ **통화·네트워크가 여러 개다**(1/1, 3/3, 6/3). **절대 합산하지 마라.** 축을 분리해 내려준다.

---

## 2. 공통 규칙 (전 엔드포인트)

### 2-1. 그룹 스코프 — 예외 없이

```sql
-- 수익 분배 축 (settlement_daily_fees)
JOIN partners sp ON sp.id = f.source_partner_id AND sp.root_partner_id = #{hqId}
```

`hqId` 는 **세션에서만** 얻는다. 파라미터로 받는 엔드포인트를 만들지 않는다.

### 2-2. ☠️ 버킷은 `participant_type` 이 1차 축

```sql
CASE WHEN f.participant_type = 'SYSTEM'          THEN 'SYSTEM'
     WHEN f.participant_partner_id = #{hqId}     THEN 'HQ'
     ELSE 'SUB' END
```

**`fee_role` 로 가르지 마라.** 실측에 `fee_role='BUYER_SHARE'` + `participant_type='SYSTEM'` 인
행이 9건 / 4.153091 USDT 있다. `fee_role` 로 가르면 이 금액이 "내 수익"으로 잘못 들어간다.

`fee_role` 은 **명목 표시용 2차 축**이다.

### 2-3. ☠️ 포함해야 하는 값들

| 컬럼 | 유효값 | 함정 |
|---|---|---|
| `fee_source` | `DEPOSIT`, `P2P` **2종뿐** | `P2P_WITHDRAW` 는 **존재하지 않는다**. 출금 재원은 `fee_role` 로 구분 |
| `fee_role` | `BUYER_SHARE`, `SYSTEM`, `WITHDRAW_SHARE`, `WITHDRAW_SYSTEM`, `REBATE`, **`WITHDRAW_BONUS`** | `WITHDRAW_BONUS` 는 v2.8 deprecated 지만 **과거 행 8건 / 20.998138 USDT 실재**(전부 **root 36** 그룹 — 그룹 51 에는 0건이다). 빼면 "미분배"로 오인된다 |

☠️ **통화·네트워크를 합산하지 마라 — 검산 쿼리에서도.** 운영 통화는 1 USDT(BSC) / 3 USDT(TRON) /
4 BNB / 6 TRX 이고 그룹 51 원장 FEE 에 이미 통화 4·6 행이 있다. 파트너로만 묶으면 BNB·TRX·USDT 를
한 스칼라로 더하게 되고, 통화 A 의 `+X` 와 통화 B 의 `−X` 가 **상쇄되어 갭이 사라진다.**
이 규칙은 응답뿐 아니라 **G1 대사 산식의 키**에도 적용된다(§6 정정본 참조).

`fee_role` 을 화이트리스트로 필터하지 마라 — **전부 집계하고 버킷만 나눈다.** 새 값이 생겨도 유실되지 않는다.

### 2-4. ☠️ `share_rate` 를 응답에 넣지 마라

`share_rate` 와 `share_amount` 는 **단위가 다르다** (rate=0.2 인데 amount=total_fee 전액인 행이
정상). 비율이 필요하면 **금액에서 역산**한다. 두 축을 같이 내려보내면 화면에서 불일치가 난다.

### 2-5. 금액 직렬화

`DECIMAL(36,18)` → **문자열**로 직렬화한다. `double` 로 내려보내면 정밀도가 깨진다.

### 2-6. 응답 `meta`

모든 집계 응답에 포함:
```json
{ "asOf": "2026-08-24", "aggregatedThrough": "2026-08-24", "currencyId": 1, "networkId": 1 }
```
`aggregatedThrough` = `SELECT MAX(settlement_date) FROM settlement_daily_fees` (§4 참조).

---

## 3. ☠️ R4 — REBATE 행은 그룹 스코프를 양방향으로 깬다

`aggregateDailyP2pFees` 의 `REBATE` 행은 **`source` = 입금 파트너, `participant` = 출금 파트너**다.
크로스그룹 매칭(`p2p_cross_group_matching`)에서 이 둘은 **다른 그룹**일 수 있다.

| 방향 | 문제 |
|---|---|
| source 축으로만 조회 | 내 그룹 출금 파트너가 **받은** 리베이트 수입이 안 보인다 |
| source 축 결과를 그대로 노출 | 폭포수에 **타 그룹 파트너의 id·이름·금액**이 들어온다 → D1 경계 위반 |

**해결 — 두 축을 분리해 집계한다.**

```
(a) 발생 축  source_partner ∈ 내 그룹   → "우리 그룹이 만든 수수료가 어디로 갔나"
              ⚠️ participant 가 내 그룹 밖이면 **"외부"로 익명 합산.**
                 파트너 id·이름을 절대 내려보내지 않는다.

(b) 귀속 축  participant_partner_id ∈ 내 그룹  → "내 그룹 구성원이 받은 수익"
              타 그룹에서 발생한 리베이트 수입이 여기 잡힌다.
```

`GET /revenue/breakdown` 은 **(a)**, `GET /revenue/by-partner` 는 **(b)** 를 쓴다.
응답 필드명·JavaDoc 에 어느 축인지 명시한다 — 섞이면 금액이 이중계상된다.

---

## 4. 집계 기준일 (G1 절단)

```sql
SELECT MAX(settlement_date) FROM settlement_daily_fees   -- 실측: 2026-08-24
```

**하드코딩 `D-1` 금지.** 집계 잡이 하루 실패하면 어긋난다.

---

## 5. 엔드포인트

`HqRevenueController` (`/api/hq/revenue`) + `HqDashboardController` (`/api/hq/dashboard`).
서비스 `HqRevenueService`, 매퍼 `HqRevenueMapper`.

### 5-1. `GET /api/hq/dashboard/summary?from&to`

카드 세트. 통화·네트워크별 배열로 내려준다(합산 금지).

☠️ **이 화면은 전부 귀속 축(`meta.axis = PARTICIPANT`)이다** (2026-08-25 결정, 리뷰 P1-4).
"내 수익"의 자연어 의미는 *내가 받은 것* = 귀속 축이고, `balances`·`by-partner`·`realizations` 가
이미 전부 귀속 축이라 대시보드만 발생 축이면 같은 화면의 두 카드가 어긋난다
(실측: participant 49 는 root 36 발생분에서 `WITHDRAW_BONUS 0.019929 + REBATE 0.065297 = 0.085226`
을 받는데 발생 축 "내 수익"에는 그 금액이 없다). 발생 축은 `/revenue/breakdown` 이 담당한다.

| 필드 | 소스 |
|---|---|
| `myRevenue[]` | **귀속 축** `participant_partner_id = hqId` 합 (통화·네트워크별) |
| `balances[]` | `settlement_balances` where `participant_partner_id = hqId` — `unrealized`, `realized`, `totalWithdrawn` |
| `realizationThresholdUsd` | **10** — 미실현 카드에 병기할 기준값 (§1) |
| `groupPartnerCount` | `partners` where `root_partner_id = hqId` |
| `reconcile` | §5-6 요약 (갭 유무 boolean + 최대 갭) |

⚠️ **"가용 잔액" 이라는 단어는 `realized_balance` 에만 쓴다.** 다른 값에 쓰면 파트너 콘솔에서
반복된 문의가 그대로 재현된다.

### 5-2. `GET /api/hq/dashboard/trend?from&to`

일별 추이. `settlement_date × fee_source × 버킷` 으로 그룹핑.
실측 형태는 부록 A / 아래 검증 쿼리 참조.

### 5-3. `GET /api/hq/revenue/breakdown?from&to&feeSource`

**(a) 발생 축.** 폭포수 노드.

```
분배 총액
  ├ 내 수익 (HQ)
  ├ 하위 배분        ← 파트너별 내역 포함 (내 그룹 한정)
  ├ 리베이트 유출     ← participant 가 그룹 밖이면 "외부" 익명 합산 (§3)
  ├ 시스템           ← 금액만. 요율·비중·증감 붙이지 않는다 (설계 D1)
  └ 미분배 (G1)      ← §5-6
```

`feeSource` 파라미터는 `DEPOSIT` | `P2P` | `ALL`. **재원을 섞어 하나의 요율로 표시하지 마라** —
DEPOSIT 은 트리 워크, P2P 는 체인 합으로 **분배 산식 자체가 다르다.**

☠️ **필터(기간·재원·통화·네트워크)가 하나라도 걸리면 `unallocated` 를 `null` 로 비우고
`unallocatedApplicable=false` 를 함께 내린다** (리뷰 P1-3). G1 은 전체 누적 정합성 지표라
필터된 `axes[]` 옆에 형제 노드로 그리면 **노드 합 ≠ 총액** 이 되어 폭포수 불변식이 깨진다.
필터 상태에서 미분배가 필요하면 `/reconcile` 을 따로 호출한다.

⚠️ **전 버킷이 0 인 축은 `axes[]` 에서 제외한다** (리뷰 P2-6) — 실측 그룹 51 의 통화 6 / 네트워크 3 이
전부 0 이라, 넣으면 빈 폭포수가 그려진다.

### 5-4. `GET /api/hq/revenue/by-partner?from&to`

**(b) 귀속 축.** `participant_partner_id ∈ 내 그룹` 기준 파트너별 수익.
`depth` 무제한(`root_partner_id` 사용, 재귀 CTE 금지).

### 5-5. `GET /api/hq/revenue/realizations` (XPage)

`settlement_realizations` where **`participant_partner_id = hqId`**.

☠️ **`partner_id` 로 조회하지 마라.** 그건 *발생(출처) 파트너*이고 수익자가 아니다
(v2.7 에서 의미 확정. 혼용 버그가 2026-06-17·2026-08-05 두 번 발생).

`XPagination`(1-based) 첫 파라미터 / `Class<?> cls` 마지막 파라미터 / `XPage<T>` 반환 — 3규칙 준수.
**ORDER BY·LIMIT·COUNT 직접 작성 금지** (`XResultInterceptor` 자동 처리).

⚠️ 현재 실측 **0건**이다. 빈 목록이 정상이며, 화면은 "실현 기준 10 USD 미달" 을 안내한다.

### 5-6. `GET /api/hq/revenue/reconcile?from&to`

| 갭 | 산식 |
|---|---|
| **G1 미분배** | `SUM(ledger FEE WHERE created_at < aggregatedThrough + 1일)` − `SUM(share_amount WHERE settlement_date <= aggregatedThrough)` |
| G2 미실현 | `settlement_balances.unrealized_balance` |
| G3 미인출 | **`settlement_balances.realized_balance` 그대로** |
| G4 누적 인출 | `total_withdrawn` |

☠️ **G3 를 `실현 − 인출` 로 계산하지 마라.** `realized_balance` 는 인출 시 이미 차감된 **순액**이다
(`withdrawSettlement`: realized −= amount, total_withdrawn += amount). 또 빼면 이중차감이고,
이는 2026-07-13 에 고쳐진 버그를 되살리는 것이다.

**경보 규칙** — 설계 부록 A-5 + 반올림 허용 오차(2026-08-25 개정):
```
|gap| <= 1e-9  → 갭 아님 (배분 반올림 잡음)   ← ★ 아래 "허용 오차" 절
gap  <  -1e-9  → 즉시 경보 (산식상 불가능한 방향 = 항상 사고)
gap  >  +1e-9  → 절단 후에도 남을 때만 경보
당일분         → 갭이 아니라 별도 지표 "오늘 발생(집계 대기)"
```

#### ☠️ 반올림 허용 오차 — `negativeGap` 이 항상 켜지던 P0 거짓 경보

로컬 E2E 에서 그룹 51(qndk11) 이 `hasGap=true, negativeGap=true` 를 냈다. **사고가 아니라 반올림이다.**

```
cur1/net1  징수 16.775088884442598337
           분배 16.775088884442598338
           gap  -0.000000000000000001   ← 1e-18 = DECIMAL(36,18) 최소 단위
byPartner  P694411(보물섬) cur1/net1 도 동일
```

`SettlementService.proportionalShare` 가 참여자마다 `totalFee × margin ÷ depositFeeRate` 를
**scale 18 / HALF_UP** 로 나눈 뒤 합치기 때문에, **산식이 정상 동작해도 최소 단위 잡음이 반드시 남는다.**
이걸 "음수 갭 = 항상 사고" 규칙에 그대로 태우면 qndk11 은 **콘솔 첫 화면에서 매번 적색 경보**를 본다 —
부록 A-5 가 못박은 *"매일 울리는 경보가 진짜 사고를 덮는다"* 의 재현이다.

| 항목 | 값 |
|---|---|
| 허용 오차 | **`0.000000001` (1e-9)**, 경계 포함(`ABS(gap) <= tolerance` → 갭 없음) |
| 단일 소스 | `partner-api/.../util/HqGapTolerance.java` (`AMOUNT` · `alarmSignum` · `isRoundingNoise`) |
| 적용 지점 | `hasGap` · `negativeGap` · `AxisGap.negativeGap` · `maxPartnerGap*` 선정 · `/stats/partners` 의 `hasGap` |
| 응답 노출 | `summary.gapTolerance` (화면이 "이 값 이하는 반올림으로 간주" 를 말할 수 있어야 한다) |

**임계치 근거 (위아래 양쪽을 막는다)**

- **위** — 실측된 *진짜* 음수 갭은 부록 A-5 의 `test(1)` **−0.006529**. 1e-9 는 그보다 **650만 배** 작아
  절대 흡수하지 못한다. 화면 표시 단위도 소수 6자리라, **화면에 0 이 아닌 숫자로 보이는 갭은 예외 없이 경보가 뜬다.**
- **아래** — 잡음 크기는 (한 키에 접힌 분배 행 수) × 1e-18. 1e-9 에 닿으려면 같은
  `(파트너 × 통화 × 네트워크)` 키에 **10억 행**이 쌓여야 한다(실측 최대 20건).
- 코드베이스 선례 `MinFeeRateCalculator.TOLERANCE = 0.000001` 을 그대로 쓰지 **않았다.** 저쪽은 **요율**
  비교(소수 6자리)라 1e-6 이 최소 단위지만 여기는 **금액**(소수 18자리)이다. 1e-6 이면 화면이
  `0.000001` 로 또렷이 보여주는 갭이 경보에서 빠지는 구간이 생긴다.

☠️ **`gapAmount` 값 자체는 절대 0 으로 뭉개지 않는다.** 표시는 하되 경보만 끈다 —
값을 지우면 잡음이 *커졌는지*(= 반올림이 아닌 다른 원인이 끼었는지) 나중에 추적할 수 없다.
화면(`ReconcileAlert.vue` · `RevenueReconcileView.vue` · `WaterfallCard.vue`)은 오차 범위 행을
정상(무채색)으로 그리고 **"반올림 오차 범위"** 라고 부기한다.

G1 원장 측 그룹 조인은 `JOIN partners p ON p.id = l.partner_id AND p.root_partner_id = #{hqId}`.

⚠️ `entry_type` 유효값은 `FEE` 다. **`P2P_WITHDRAW_FEE` 는 `entry_type` 이 아니라
`reference_type`** 이다 — `entry_type IN ('FEE','P2P_WITHDRAW_FEE')` 로 짜면 0건이 나온다.

---

## 6. 검증 쿼리 (구현 후 이 결과와 대조)

☠️ **초판의 G1 검산 쿼리는 틀렸다**(2026-08-25 적대적 리뷰 P1-1). 통화·네트워크를 합산한
단일 스칼라라서, 통화 A 의 `+X` 와 통화 B 의 `−X` 가 상쇄되면 갭이 사라진다 — §2-3 "통화·네트워크
합산 금지"를 검산 쿼리 자신이 위반하고 있었다. 아래가 정정본이다. **구현은 지침서가 아니라 이 규칙을
따른다.**

```sql
-- 5-3 breakdown 검산 — 축별로 본다. 합계: SYSTEM 8.720778 / HQ 9.282311 / 합 18.003089
--   축 1/1: HQ 8.668311 / SYSTEM 8.106778   축 3/3: HQ 0.614 / SYSTEM 0.614   축 6/3: 0 / 0
SELECT f.currency_id, f.network_id,
       CASE WHEN f.participant_type='SYSTEM' THEN 'SYSTEM'
            WHEN f.participant_partner_id=51 THEN 'HQ' ELSE 'SUB' END AS bucket,
       ROUND(SUM(f.share_amount),6)
  FROM settlement_daily_fees f
  JOIN partners sp ON sp.id=f.source_partner_id AND sp.root_partner_id=51
 GROUP BY f.currency_id, f.network_id, bucket;

-- 5-6 G1 검산 — (파트너 × 통화 × 네트워크) 키로. 0 이 아닌 행이 하나라도 있으면 경보다.
--   MySQL 에 FULL OUTER JOIN 이 없어 LEFT JOIN 으로 짜면 "분배만 있는 축"(음수 갭)이 통째로 사라진다.
SELECT u.partner_id, u.currency_id, u.network_id,
       ROUND(SUM(u.collected),6)   AS collected,
       ROUND(SUM(u.distributed),6) AS distributed,
       ROUND(SUM(u.collected) - SUM(u.distributed),6) AS gap
  FROM (
    SELECT l.partner_id, l.currency_id, l.network_id, SUM(l.amount) AS collected, 0 AS distributed
      FROM ledger_entries l
      JOIN partners p ON p.id=l.partner_id AND p.root_partner_id=51
     WHERE l.entry_type='FEE' AND l.created_at < DATE_ADD('2026-08-24', INTERVAL 1 DAY)
     GROUP BY l.partner_id, l.currency_id, l.network_id
    UNION ALL
    SELECT f.source_partner_id, f.currency_id, f.network_id, 0, SUM(f.share_amount)
      FROM settlement_daily_fees f
      JOIN partners sp ON sp.id=f.source_partner_id AND sp.root_partner_id=51
     WHERE f.settlement_date <= '2026-08-24'
     GROUP BY f.source_partner_id, f.currency_id, f.network_id
  ) u
 GROUP BY u.partner_id, u.currency_id, u.network_id
 HAVING gap <> 0;
```

**경보 판정은 건별이다.** 위 쿼리가 0행이어야 정상이고, 한 행이라도 나오면 경보다.
합계 부호로 판정하면 파트너 A `+5` / B `−5` 가 상쇄되어 갭이 은폐된다(P1-2).

> ### ⚠️ 이 검산 SQL 의 `ROUND(...,6)` 이 1e-18 반올림 오차를 **감췄다** (2026-08-25)
>
> 위 쿼리는 `ROUND(...,6)` 으로 갭을 보므로 `-0.000000000000000001` 이 **`0.000000` 으로 접혀**
> `HAVING gap <> 0` 에서 통째로 탈락한다. 그래서 "그룹 51 갭 0.000000 ✅" 라는 결론이 나왔다.
> **API 는 full precision 이라 같은 데이터에서 `negativeGap=true` 가 떴다** — 문서와 구현이
> 서로 다른 사실을 말한 것이다.
>
> **다음 사람에게**: 검산 SQL 이 초록이라고 "갭 없음"으로 단정하지 마라. `ROUND` 를 빼고
> `HAVING SUM(u.collected) - SUM(u.distributed) <> 0` 로 다시 보면 잡음 행이 드러난다.
> 반대로 **잡음이 보인다고 사고로 판정하지도 마라** — 판정 기준은 §5-6 의 허용 오차 `1e-9` 다.
> `ROUND(...,6)` 은 **표시용**이지 판정 기준이 아니다(오히려 진짜 경계인 1e-9 보다 1000배 느슨하다).

---

## 7. 하지 말 것

- `hq_group_daily_stats` 등 **신규 테이블 생성** (이 Phase 는 DDL 변경 없음)
- 재귀 CTE 로 그룹 판정 (`root_partner_id` 사용 — depth 제한이 금액 오류가 된다)
- `fee_role` 을 버킷 1차 축으로 사용
- `share_rate` 를 응답에 포함
- `partner_id` 기준 실현 조회
- 통화·네트워크 합산
- XML MyBatis 매퍼 / `@Data` / DTO 멤버 JavaDoc 누락
- ⚠️ `<script>` 내부에서 `<`, `<=`, `<>` 직접 사용 — **기동 시 SAXParseException 으로 전 서비스 다운**
  (2026-06-11 운영 장애). `&lt;` 또는 `<![CDATA[ ]]>` 사용, 같지 않음은 `!=`
- **커밋·push**

---

## 8. 참고

| 목적 | 경로 |
|---|---|
| 설계 원본 | `v2-docs/HQ_CONSOLE_DESIGN.md` (§2, §3, §4-A/B, 부록 A) |
| 인증 (선행) | `partner-api/.../controller/HqAuthController.java`, `config/HqSecurityFilter.java` |
| 집계 로직 | `core/.../settlement/SettlementService.java` (`aggregateDailyFees`, `aggregateDailyP2pFees`, `realizeFeesForGroup`) |
| 매퍼 규약 원형 | `partner-api/.../mapper/PartnerSettlementMapper.java` |
| 컨트롤러 원형 | `partner-api/.../controller/PartnerSettlementController.java` |
