fix(calculate): 관리자 정산 크리에이터 식별자를 보정한다
This commit is contained in:
170
docs/20260804_관리자정산크리에이터번호적용/plan-task.md
Normal file
170
docs/20260804_관리자정산크리에이터번호적용/plan-task.md
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
# 관리자 정산 크리에이터 번호 적용 구현 계획
|
||||||
|
|
||||||
|
| 문서 항목 | 내용 |
|
||||||
|
|---|---|
|
||||||
|
| 상태 | 구현 완료 |
|
||||||
|
| 작성일 | 2026-08-04 |
|
||||||
|
| 요구사항 기준 | `docs/20260804_관리자정산크리에이터번호적용/prd.md` |
|
||||||
|
| 현재 Phase | Phase 1 완료 |
|
||||||
|
| 현재 활성 Goal | 없음 |
|
||||||
|
|
||||||
|
## 목표
|
||||||
|
|
||||||
|
이메일이 없는 크리에이터도 관리자 크리에이터별 정산에서 오류 없이 조회되고 `creatorId`로 구분된다.
|
||||||
|
|
||||||
|
## 현재 상태
|
||||||
|
|
||||||
|
| Phase | 상태 | 완료 Task | 활성/다음 Goal | 차단 또는 남은 조건 |
|
||||||
|
|---:|---|---:|---|---|
|
||||||
|
| 1 | 완료 | `2/2` | 없음 | 없음 |
|
||||||
|
|
||||||
|
## 범위
|
||||||
|
|
||||||
|
### 포함
|
||||||
|
|
||||||
|
- 관리자 라이브·콘텐츠·커뮤니티 크리에이터별 정산 QueryDSL projection 변경
|
||||||
|
- 정산 query/response DTO의 `email` 제거 및 `creatorId` 추가
|
||||||
|
- 크리에이터별 정산 엑셀 이메일 컬럼 제거 및 크리에이터 번호 컬럼 추가
|
||||||
|
- null 이메일 크리에이터 QueryDSL 회귀 테스트와 엑셀 출력 테스트
|
||||||
|
|
||||||
|
### 제외
|
||||||
|
|
||||||
|
- 에이전트·채널후원 정산 변경
|
||||||
|
- `Member.email` 정책 변경
|
||||||
|
- 정산 공식·집계·필터·정렬·페이지네이션 변경
|
||||||
|
- dependency 추가와 관련 없는 리팩터링
|
||||||
|
|
||||||
|
## 기술적 제약
|
||||||
|
|
||||||
|
- Kotlin, Spring Boot 2.7.14, QueryDSL 5.0.0, JUnit 5의 기존 패턴을 유지한다.
|
||||||
|
- `@QueryProjection` 생성자 변경은 Gradle KAPT가 생성 코드를 갱신하도록 하고 생성 파일은 직접 수정하지 않는다.
|
||||||
|
- 구현은 RED → GREEN → REFACTOR 순서로 진행한다.
|
||||||
|
- Gradle 명령은 QueryDSL 생성 코드 충돌을 피하기 위해 순차 실행한다.
|
||||||
|
|
||||||
|
### Phase 1: 정산 projection 및 출력 변경
|
||||||
|
|
||||||
|
**Phase 결과:** null 이메일 크리에이터 정산 조회와 creatorId 기반 API·엑셀 출력이 동작한다.
|
||||||
|
|
||||||
|
**선행조건:** PRD와 본 계획 문서 작성 완료.
|
||||||
|
|
||||||
|
#### Task 1.1 QueryDSL 정산 응답에 creatorId 적용
|
||||||
|
|
||||||
|
**Goal 실행 `P1-T1`:** null 이메일 크리에이터의 세 정산 조회가 creatorId를 반환하도록 한다.
|
||||||
|
|
||||||
|
- **시작 조건:** `CALC-001`, `CALC-002`, `CALC-004` 확정.
|
||||||
|
- **완료 증거:** RED/GREEN 결과와 focused test 기록.
|
||||||
|
- **범위 밖:** 엑셀 출력 변경은 `P1-T2`에서 처리한다.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateQueryRepositoryTest.kt`
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/GetCalculateByCreatorQueryData.kt`
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/GetCalculateByCreatorItem.kt`
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateQueryRepository.kt`
|
||||||
|
|
||||||
|
- [x] **RED:** email이 null인 크리에이터의 라이브·콘텐츠·커뮤니티 정산 조회가 `creatorId`를 반환하는 통합 테스트를 작성한다.
|
||||||
|
- [x] **RED 확인:** `./gradlew test --tests kr.co.vividnext.sodalive.admin.calculate.AdminCalculateQueryRepositoryTest`를 실행해 기존 projection의 null email 생성 오류 또는 미구현 `creatorId` 계약 실패를 확인한다.
|
||||||
|
- [x] **GREEN:** 두 DTO에서 `email`을 `creatorId: Long`으로 교체하고 세 QueryDSL projection에서 `member.id`를 선택한다.
|
||||||
|
- [x] **GREEN 확인:** 같은 focused test가 성공하는지 확인한다.
|
||||||
|
- [x] **REFACTOR:** 이번 Task가 만든 중복만 정리하고 정산 계산 회귀 테스트를 실행한다.
|
||||||
|
|
||||||
|
#### Task 1.2 엑셀 크리에이터 번호 적용
|
||||||
|
|
||||||
|
**Goal 실행 `P1-T2`:** 크리에이터별 정산 엑셀에서 이메일 대신 크리에이터 번호를 출력한다.
|
||||||
|
|
||||||
|
- **시작 조건:** `P1-T1` 완료.
|
||||||
|
- **완료 증거:** 엑셀 헤더·값 테스트와 focused test 기록.
|
||||||
|
- **범위 밖:** 다른 정산 엑셀 형식 변경.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateServiceTest.kt`
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateService.kt`
|
||||||
|
|
||||||
|
- [x] **RED:** 크리에이터별 정산 엑셀의 첫 헤더와 값이 크리에이터 번호인지 검증하는 테스트를 작성한다.
|
||||||
|
- [x] **RED 확인:** `./gradlew test --tests kr.co.vividnext.sodalive.admin.calculate.AdminCalculateServiceTest`를 실행해 남아 있는 `item.email` 계약 때문에 컴파일이 실패하는지 확인한다.
|
||||||
|
- [x] **GREEN:** 엑셀 첫 헤더를 `크리에이터 번호`로 교체하고 `creatorId`를 기록한다.
|
||||||
|
- [x] **GREEN 확인:** 같은 focused test가 성공하는지 확인한다.
|
||||||
|
- [x] **REFACTOR:** 나머지 엑셀 컬럼과 계산 결과가 유지되는지 회귀 확인한다.
|
||||||
|
|
||||||
|
### 완료 조건
|
||||||
|
|
||||||
|
- [x] `P1-T1`, `P1-T2`의 체크박스와 완료 증거가 충족됐다.
|
||||||
|
- [x] 문서와 구현의 차이가 없다.
|
||||||
|
|
||||||
|
### Phase 1 Gate
|
||||||
|
|
||||||
|
**Goal 실행 `P1-GATE`:** 변경 범위의 기능·컴파일·포맷을 최종 판정한다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew compileKotlin
|
||||||
|
./gradlew test --tests kr.co.vividnext.sodalive.admin.calculate.AdminCalculateQueryRepositoryTest --tests kr.co.vividnext.sodalive.admin.calculate.AdminCalculateServiceTest --tests kr.co.vividnext.sodalive.admin.calculate.ContentSettlementCalculationTest
|
||||||
|
./gradlew ktlintCheck
|
||||||
|
./gradlew tasks --all
|
||||||
|
git diff --check
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected:** 모든 Gradle 명령이 `BUILD SUCCESSFUL`, 테스트가 모두 통과하고 `git diff --check` 출력이 없다.
|
||||||
|
|
||||||
|
전체 테스트는 관리자 크리에이터별 정산의 projection/DTO/엑셀 경계로 변경이 제한되고 focused test와 컴파일로 세 쿼리 호출부를 검증하므로 생략한다.
|
||||||
|
|
||||||
|
## 실행 순서와 의존성
|
||||||
|
|
||||||
|
| 순서 | Goal | 선행조건 | 병행 가능 | 차단 시 다음 행동 |
|
||||||
|
|---:|---|---|---|---|
|
||||||
|
| 1 | `P1-T1` | 문서 작성 완료 | 아니요 | RED 실패 원인 재확인 |
|
||||||
|
| 2 | `P1-T2` | `P1-T1` 완료 | 아니요 | 엑셀 계약 재확인 |
|
||||||
|
| 3 | `P1-GATE` | Phase 1 Task 전체 | 아니요 | 실패 소유 Task에 회귀 수정 기록 |
|
||||||
|
|
||||||
|
## 변경 금지 항목
|
||||||
|
|
||||||
|
- QueryDSL 생성 파일을 직접 수정하지 않는다.
|
||||||
|
- 정산 계산식과 쿼리 집계 조건을 변경하지 않는다.
|
||||||
|
- 테스트를 삭제·skip·완화하지 않는다.
|
||||||
|
- 요청 범위 밖의 정산 API를 변경하지 않는다.
|
||||||
|
|
||||||
|
## Progress
|
||||||
|
|
||||||
|
### `P1-T1` 1차 실행 — 2026-08-04
|
||||||
|
|
||||||
|
- 상태: 진행 중
|
||||||
|
- 무엇을: PRD와 구현 계획을 작성하고 QueryDSL 통합 테스트 seam을 확정했다.
|
||||||
|
- 왜: production 변경 전에 요구사항과 TDD 완료 기준을 고정하기 위해서다.
|
||||||
|
- 어떻게:
|
||||||
|
- `docs/sample/sample-prd.md`, `docs/sample/sample-plan-task.md` 확인 — 완료
|
||||||
|
- `AdminCalculateQueryRepository`와 관련 DTO 호출 경로 확인 — 완료
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.admin.calculate.AdminCalculateQueryRepositoryTest` — RED 확인, `creatorId` 미구현으로 `compileTestKotlin` 실패
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.admin.calculate.AdminCalculateServiceTest` — RED 확인, 남아 있는 `item.email` 참조로 `compileKotlin` 실패
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.admin.calculate.AdminCalculateQueryRepositoryTest --tests kr.co.vividnext.sodalive.admin.calculate.AdminCalculateServiceTest` — GREEN, `BUILD SUCCESSFUL`
|
||||||
|
- 남은 항목: Phase Gate 검증.
|
||||||
|
- 다음 행동: 컴파일·focused 회귀·ktlint·문서 검증을 순차 실행한다.
|
||||||
|
|
||||||
|
### `P1-GATE` 1차 실행 — 2026-08-04
|
||||||
|
|
||||||
|
- 상태: 완료
|
||||||
|
- 무엇을: creatorId projection·응답·엑셀 변경과 null 이메일 회귀 방지를 검증했다.
|
||||||
|
- 왜: `CALC-001`~`CALC-004`의 수용 기준과 기존 계산 불변성을 최종 판정하기 위해서다.
|
||||||
|
- 어떻게:
|
||||||
|
- `./gradlew compileKotlin` — `BUILD SUCCESSFUL`
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.admin.calculate.AdminCalculateQueryRepositoryTest --tests kr.co.vividnext.sodalive.admin.calculate.AdminCalculateServiceTest --tests kr.co.vividnext.sodalive.admin.calculate.ContentSettlementCalculationTest` — 11 tests, failures 0, errors 0
|
||||||
|
- `./gradlew ktlintCheck` — `BUILD SUCCESSFUL`
|
||||||
|
- `./gradlew build -x test` — `BUILD SUCCESSFUL`
|
||||||
|
- `git diff --check` — 출력 없음
|
||||||
|
- 독립 Oracle 리뷰 — `APPROVED`, 차단 이슈 없음
|
||||||
|
- 수동 검증: QueryDSL H2 통합 테스트로 null 이메일 세 정산 조회를 실행하고, 실제 XLSX workbook의 헤더와 numeric creatorId 셀을 확인했다.
|
||||||
|
- 전체 테스트 생략: 변경이 관리자 크리에이터별 정산의 공용 projection/DTO/엑셀 경계에 한정되어 focused 통합·서비스·계산 테스트와 전체 빌드로 직접 영향 범위를 검증했다.
|
||||||
|
- 남은 항목: 없음.
|
||||||
|
- 다음 행동: 없음.
|
||||||
|
|
||||||
|
## Decision Log
|
||||||
|
|
||||||
|
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 Goal/문서 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 2026-08-04 | `DEC-001` | 확정 | email을 제거하고 creatorId를 projection·응답에 추가한다. | PRD `DEC-001` | `P1-T1` |
|
||||||
|
| 2026-08-04 | `DEC-002` | 확정 | 엑셀 이메일 컬럼을 크리에이터 번호로 교체한다. | PRD `DEC-002` | `P1-T2` |
|
||||||
|
|
||||||
|
## 발견된 문제
|
||||||
|
|
||||||
|
| ID | 심각도 | 상태 | 발견 내용 | 영향 Goal | 처리 계획 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| `ISSUE-001` | High | 확정 | nullable `Member.email`이 non-null QueryProjection 생성자로 전달되어 정산 조회가 실패한다. | `P1-T1` | creatorId로 projection 계약 교체 |
|
||||||
73
docs/20260804_관리자정산크리에이터번호적용/prd.md
Normal file
73
docs/20260804_관리자정산크리에이터번호적용/prd.md
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# 관리자 정산 크리에이터 번호 적용 PRD
|
||||||
|
|
||||||
|
## 문서 정보
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
|---|---|
|
||||||
|
| 문서 상태 | 구현 완료 |
|
||||||
|
| 작성일 | 2026-08-04 |
|
||||||
|
| 최종 수정일 | 2026-08-04 |
|
||||||
|
| 대상 제품 | 관리자 크리에이터별 정산 조회 및 엑셀 |
|
||||||
|
| 작성자·결정권자 | 사용자 |
|
||||||
|
| 관련 구현 계획 | `docs/20260804_관리자정산크리에이터번호적용/plan-task.md` |
|
||||||
|
|
||||||
|
## 1. Overview
|
||||||
|
|
||||||
|
관리자 크리에이터별 정산에서 nullable인 `Member.email`을 필수 QueryDSL projection 값으로 사용해 발생하는 조회 오류를 제거한다. 정산 대상 식별값은 이메일 대신 non-null PK인 `creatorId`를 사용한다.
|
||||||
|
|
||||||
|
## 2. Problem Statement
|
||||||
|
|
||||||
|
- `Member.email`은 nullable이지만 `GetCalculateByCreatorQueryData.email`은 non-null `String`이다.
|
||||||
|
- 이메일이 없는 크리에이터가 정산 결과에 포함되면 QueryDSL이 DTO 생성 중 `ExpressionException`을 발생시킨다.
|
||||||
|
- 이메일은 정산 계산식에 사용되지 않으므로 필수 projection 값으로 유지할 이유가 없다.
|
||||||
|
|
||||||
|
문제 해결 여부는 이메일이 null인 크리에이터의 정산 조회가 성공하고 `creatorId`를 반환하는지로 판단한다.
|
||||||
|
|
||||||
|
## 3. Goals
|
||||||
|
|
||||||
|
- 라이브·콘텐츠·커뮤니티 크리에이터별 정산 projection에서 `email`을 제거한다.
|
||||||
|
- 정산 응답에 `creatorId`를 추가한다.
|
||||||
|
- 크리에이터별 정산 엑셀에서 이메일을 제거하고 크리에이터 번호를 제공한다.
|
||||||
|
- 기존 정산 금액 계산, 집계, 필터, 정렬과 페이지네이션을 유지한다.
|
||||||
|
|
||||||
|
## 4. Non-Goals
|
||||||
|
|
||||||
|
- `Member.email`의 nullable 정책을 변경하지 않는다.
|
||||||
|
- 이메일이 없는 회원의 다른 기능을 수정하지 않는다.
|
||||||
|
- 에이전트 정산과 채널후원 정산 API를 변경하지 않는다.
|
||||||
|
- 정산 공식 또는 정산 비율을 변경하지 않는다.
|
||||||
|
|
||||||
|
## 5. 기능 요구사항
|
||||||
|
|
||||||
|
| ID | 상태 | 요구사항 | 수용 기준 | Goal 연결 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `CALC-001` | 확정 | 크리에이터별 정산 조회는 이메일 대신 크리에이터 번호를 반환한다. | 응답 item에 `creatorId: Long`이 있고 `email`이 없다. | `P1-T1` |
|
||||||
|
| `CALC-002` | 확정 | 이메일이 없는 크리에이터도 정산 조회 대상에 포함된다. | null 이메일 크리에이터의 라이브·콘텐츠·커뮤니티 정산 QueryDSL 조회가 예외 없이 성공한다. | `P1-T1` |
|
||||||
|
| `CALC-003` | 확정 | 크리에이터별 정산 엑셀은 이메일 대신 크리에이터 번호를 제공한다. | 첫 헤더가 `크리에이터 번호`이고 데이터 셀에 `creatorId`가 기록된다. | `P1-T2` |
|
||||||
|
| `CALC-004` | 확정 | 기존 정산 계산 결과를 유지한다. | `totalCan`, 원화, 결제수수료, 정산금액, 원천세와 입금액 계산 회귀 테스트가 통과한다. | `P1-T1`, `P1-GATE` |
|
||||||
|
|
||||||
|
## 6. API 계약
|
||||||
|
|
||||||
|
대상 endpoint는 다음과 같다.
|
||||||
|
|
||||||
|
- `GET /admin/calculate/live-by-creator`
|
||||||
|
- `GET /admin/calculate/content-by-creator`
|
||||||
|
- `GET /admin/calculate/community-by-creator`
|
||||||
|
- 위 세 endpoint의 `/excel` 다운로드
|
||||||
|
|
||||||
|
조회 응답 item의 `email: String`을 제거하고 `creatorId: Long`을 추가한다. 요청 파라미터와 응답의 나머지 필드는 변경하지 않는다.
|
||||||
|
|
||||||
|
## 7. 성공 기준
|
||||||
|
|
||||||
|
- [x] null 이메일 크리에이터에 대한 세 종류의 크리에이터별 정산 조회가 성공한다.
|
||||||
|
- [x] 조회 응답은 `creatorId`를 포함하고 `email`을 포함하지 않는다.
|
||||||
|
- [x] 엑셀은 `크리에이터 번호` 컬럼을 포함하고 이메일 컬럼을 포함하지 않는다.
|
||||||
|
- [x] 기존 정산 계산 및 관련 회귀 테스트가 통과한다.
|
||||||
|
|
||||||
|
## 8. Decision Log
|
||||||
|
|
||||||
|
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 요구사항·Goal |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 2026-08-04 | `DEC-001` | 확정 | 정산에서 email을 제거하고 creatorId를 사용한다. | email은 계산에 사용되지 않고 nullable이라 projection 오류를 발생시킨다. | `CALC-001`, `CALC-002`, `P1-T1` |
|
||||||
|
| 2026-08-04 | `DEC-002` | 확정 | 엑셀의 이메일 컬럼을 크리에이터 번호 컬럼으로 교체한다. | 사용자가 이메일 제거와 creatorId 기반 구분을 확정했다. | `CALC-003`, `P1-T2` |
|
||||||
|
| 2026-08-04 | `DEC-003` | 확정 | 이메일 제거에 따른 추가 식별력 보완은 하지 않는다. | 사용자가 식별력 저하는 문제가 되지 않는다고 확정했다. | Non-Goals |
|
||||||
@@ -391,7 +391,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
return queryFactory
|
return queryFactory
|
||||||
.select(
|
.select(
|
||||||
QGetCalculateByCreatorQueryData(
|
QGetCalculateByCreatorQueryData(
|
||||||
member.email,
|
member.id,
|
||||||
member.nickname,
|
member.nickname,
|
||||||
useCan.can.add(useCan.rewardCan).sum(),
|
useCan.can.add(useCan.rewardCan).sum(),
|
||||||
creatorSettlementRatio.liveSettlementRatio
|
creatorSettlementRatio.liveSettlementRatio
|
||||||
@@ -447,7 +447,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
return queryFactory
|
return queryFactory
|
||||||
.select(
|
.select(
|
||||||
QGetCalculateByCreatorQueryData(
|
QGetCalculateByCreatorQueryData(
|
||||||
member.email,
|
member.id,
|
||||||
member.nickname,
|
member.nickname,
|
||||||
order.can.sum(),
|
order.can.sum(),
|
||||||
creatorSettlementRatio.contentSettlementRatio
|
creatorSettlementRatio.contentSettlementRatio
|
||||||
@@ -504,7 +504,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
return queryFactory
|
return queryFactory
|
||||||
.select(
|
.select(
|
||||||
QGetCalculateByCreatorQueryData(
|
QGetCalculateByCreatorQueryData(
|
||||||
member.email,
|
member.id,
|
||||||
member.nickname,
|
member.nickname,
|
||||||
useCan.can.add(useCan.rewardCan).sum(),
|
useCan.can.add(useCan.rewardCan).sum(),
|
||||||
creatorSettlementRatio.communitySettlementRatio
|
creatorSettlementRatio.communitySettlementRatio
|
||||||
|
|||||||
@@ -474,7 +474,7 @@ class AdminCalculateService(
|
|||||||
return createExcelStream(
|
return createExcelStream(
|
||||||
sheetName = sheetName,
|
sheetName = sheetName,
|
||||||
headers = listOf(
|
headers = listOf(
|
||||||
"이메일",
|
"크리에이터 번호",
|
||||||
"닉네임",
|
"닉네임",
|
||||||
"총 캔",
|
"총 캔",
|
||||||
"원화",
|
"원화",
|
||||||
@@ -486,7 +486,7 @@ class AdminCalculateService(
|
|||||||
) { sheet ->
|
) { sheet ->
|
||||||
items.forEachIndexed { index, item ->
|
items.forEachIndexed { index, item ->
|
||||||
val row = sheet.createRow(index + 1)
|
val row = sheet.createRow(index + 1)
|
||||||
row.createCell(0).setCellValue(item.email)
|
row.createCell(0).setCellValue(item.creatorId.toDouble())
|
||||||
row.createCell(1).setCellValue(item.nickname)
|
row.createCell(1).setCellValue(item.nickname)
|
||||||
row.createCell(2).setCellValue(item.totalCan.toDouble())
|
row.createCell(2).setCellValue(item.totalCan.toDouble())
|
||||||
row.createCell(3).setCellValue(item.totalKrw.toDouble())
|
row.createCell(3).setCellValue(item.totalKrw.toDouble())
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package kr.co.vividnext.sodalive.admin.calculate
|
|||||||
import com.fasterxml.jackson.annotation.JsonProperty
|
import com.fasterxml.jackson.annotation.JsonProperty
|
||||||
|
|
||||||
data class GetCalculateByCreatorItem(
|
data class GetCalculateByCreatorItem(
|
||||||
@JsonProperty("email") val email: String,
|
@JsonProperty("creatorId") val creatorId: Long,
|
||||||
@JsonProperty("nickname") val nickname: String,
|
@JsonProperty("nickname") val nickname: String,
|
||||||
@JsonProperty("totalCan") val totalCan: Int,
|
@JsonProperty("totalCan") val totalCan: Int,
|
||||||
@JsonProperty("totalKrw") val totalKrw: Int,
|
@JsonProperty("totalKrw") val totalKrw: Int,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import java.math.BigDecimal
|
|||||||
import java.math.RoundingMode
|
import java.math.RoundingMode
|
||||||
|
|
||||||
data class GetCalculateByCreatorQueryData @QueryProjection constructor(
|
data class GetCalculateByCreatorQueryData @QueryProjection constructor(
|
||||||
val email: String,
|
val creatorId: Long,
|
||||||
val nickname: String,
|
val nickname: String,
|
||||||
val totalCan: Int,
|
val totalCan: Int,
|
||||||
val settlementRatio: Int?
|
val settlementRatio: Int?
|
||||||
@@ -31,7 +31,7 @@ data class GetCalculateByCreatorQueryData @QueryProjection constructor(
|
|||||||
val depositAmount = settlementAmount.subtract(tax)
|
val depositAmount = settlementAmount.subtract(tax)
|
||||||
|
|
||||||
return GetCalculateByCreatorItem(
|
return GetCalculateByCreatorItem(
|
||||||
email = email,
|
creatorId = creatorId,
|
||||||
nickname = nickname,
|
nickname = nickname,
|
||||||
totalCan = totalCan,
|
totalCan = totalCan,
|
||||||
totalKrw = totalKrw.toInt(),
|
totalKrw = totalKrw.toInt(),
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.calculate
|
||||||
|
|
||||||
|
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||||
|
import kr.co.vividnext.sodalive.can.use.CanUsage
|
||||||
|
import kr.co.vividnext.sodalive.can.use.UseCan
|
||||||
|
import kr.co.vividnext.sodalive.can.use.UseCanRepository
|
||||||
|
import kr.co.vividnext.sodalive.configs.QueryDslConfig
|
||||||
|
import kr.co.vividnext.sodalive.content.AudioContent
|
||||||
|
import kr.co.vividnext.sodalive.content.AudioContentRepository
|
||||||
|
import kr.co.vividnext.sodalive.content.order.Order
|
||||||
|
import kr.co.vividnext.sodalive.content.order.OrderRepository
|
||||||
|
import kr.co.vividnext.sodalive.content.order.OrderType
|
||||||
|
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
|
||||||
|
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity
|
||||||
|
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityRepository
|
||||||
|
import kr.co.vividnext.sodalive.live.room.LiveRoom
|
||||||
|
import kr.co.vividnext.sodalive.live.room.LiveRoomRepository
|
||||||
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberRepository
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.BeforeEach
|
||||||
|
import org.junit.jupiter.api.DisplayName
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
|
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
|
||||||
|
import org.springframework.context.annotation.Import
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import javax.persistence.EntityManager
|
||||||
|
|
||||||
|
@DataJpaTest(
|
||||||
|
properties = [
|
||||||
|
"spring.cache.type=none",
|
||||||
|
"spring.jpa.database-platform=kr.co.vividnext.sodalive.support.H2MySqlFunctionDialect"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
@Import(QueryDslConfig::class)
|
||||||
|
class AdminCalculateQueryRepositoryTest @Autowired constructor(
|
||||||
|
private val queryFactory: JPAQueryFactory,
|
||||||
|
private val memberRepository: MemberRepository,
|
||||||
|
private val liveRoomRepository: LiveRoomRepository,
|
||||||
|
private val audioContentRepository: AudioContentRepository,
|
||||||
|
private val orderRepository: OrderRepository,
|
||||||
|
private val creatorCommunityRepository: CreatorCommunityRepository,
|
||||||
|
private val useCanRepository: UseCanRepository,
|
||||||
|
private val entityManager: EntityManager
|
||||||
|
) {
|
||||||
|
private lateinit var repository: AdminCalculateQueryRepository
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
fun setup() {
|
||||||
|
repository = AdminCalculateQueryRepository(queryFactory)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("이메일이 없는 크리에이터도 크리에이터별 정산에서 번호로 조회한다")
|
||||||
|
fun shouldReturnCreatorIdForByCreatorRowsWhenCreatorEmailIsNull() {
|
||||||
|
// given
|
||||||
|
val creator = saveMember(email = null, nickname = "creator", role = MemberRole.CREATOR)
|
||||||
|
val buyer = saveMember(email = "buyer@test.com", nickname = "buyer", role = MemberRole.USER)
|
||||||
|
val createdAt = LocalDateTime.of(2026, 8, 4, 12, 0, 0)
|
||||||
|
|
||||||
|
val room = saveLiveRoom(creator)
|
||||||
|
saveUseCan(buyer, CanUsage.LIVE, 10, createdAt) { this.room = room }
|
||||||
|
|
||||||
|
val content = saveAudioContent(creator)
|
||||||
|
saveOrder(buyer, creator, content, createdAt)
|
||||||
|
|
||||||
|
val post = saveCommunityPost(creator)
|
||||||
|
saveUseCan(buyer, CanUsage.PAID_COMMUNITY_POST, 30, createdAt) { communityPost = post }
|
||||||
|
|
||||||
|
entityManager.flush()
|
||||||
|
entityManager.clear()
|
||||||
|
|
||||||
|
val startDate = LocalDateTime.of(2026, 8, 4, 0, 0, 0)
|
||||||
|
val endDate = LocalDateTime.of(2026, 8, 4, 23, 59, 59)
|
||||||
|
|
||||||
|
// when
|
||||||
|
val liveRows = repository.getCalculateLiveByCreator(startDate, endDate, offset = 0, limit = 10)
|
||||||
|
val contentRows = repository.getCalculateContentByCreator(startDate, endDate, offset = 0, limit = 10)
|
||||||
|
val communityRows = repository.getCalculateCommunityByCreator(startDate, endDate, offset = 0, limit = 10)
|
||||||
|
|
||||||
|
// then
|
||||||
|
assertEquals(listOf(creator.id), liveRows.map { it.creatorId })
|
||||||
|
assertEquals(listOf(creator.id), contentRows.map { it.creatorId })
|
||||||
|
assertEquals(listOf(creator.id), communityRows.map { it.creatorId })
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveMember(email: String?, nickname: String, role: MemberRole): Member {
|
||||||
|
return memberRepository.saveAndFlush(
|
||||||
|
Member(
|
||||||
|
email = email,
|
||||||
|
password = "password",
|
||||||
|
nickname = nickname,
|
||||||
|
role = role
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveLiveRoom(creator: Member): LiveRoom {
|
||||||
|
val room = LiveRoom(
|
||||||
|
title = "live-room",
|
||||||
|
notice = "notice",
|
||||||
|
beginDateTime = LocalDateTime.of(2026, 8, 4, 8, 0, 0),
|
||||||
|
numberOfPeople = 1,
|
||||||
|
isAdult = false,
|
||||||
|
price = 10
|
||||||
|
)
|
||||||
|
room.member = creator
|
||||||
|
return liveRoomRepository.saveAndFlush(room)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveAudioContent(creator: Member): AudioContent {
|
||||||
|
val theme = AudioContentTheme(theme = "theme", image = "theme.png")
|
||||||
|
entityManager.persist(theme)
|
||||||
|
|
||||||
|
val content = AudioContent(
|
||||||
|
title = "content",
|
||||||
|
detail = "detail",
|
||||||
|
languageCode = "ko",
|
||||||
|
price = 20
|
||||||
|
)
|
||||||
|
content.theme = theme
|
||||||
|
content.member = creator
|
||||||
|
content.isActive = true
|
||||||
|
return audioContentRepository.saveAndFlush(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveOrder(buyer: Member, creator: Member, content: AudioContent, createdAt: LocalDateTime) {
|
||||||
|
val order = Order(type = OrderType.KEEP)
|
||||||
|
order.member = buyer
|
||||||
|
order.creator = creator
|
||||||
|
order.audioContent = content
|
||||||
|
val saved = orderRepository.saveAndFlush(order)
|
||||||
|
updateCreatedAt("Order", saved.id!!, createdAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveCommunityPost(creator: Member): CreatorCommunity {
|
||||||
|
val post = CreatorCommunity(
|
||||||
|
content = "community-content",
|
||||||
|
price = 30,
|
||||||
|
isCommentAvailable = true,
|
||||||
|
isAdult = false
|
||||||
|
)
|
||||||
|
post.member = creator
|
||||||
|
return creatorCommunityRepository.saveAndFlush(post)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveUseCan(
|
||||||
|
member: Member,
|
||||||
|
usage: CanUsage,
|
||||||
|
can: Int,
|
||||||
|
createdAt: LocalDateTime,
|
||||||
|
configure: UseCan.() -> Unit
|
||||||
|
) {
|
||||||
|
val useCan = UseCan(canUsage = usage, can = can, rewardCan = 0)
|
||||||
|
useCan.member = member
|
||||||
|
useCan.configure()
|
||||||
|
val saved = useCanRepository.saveAndFlush(useCan)
|
||||||
|
updateCreatedAt("UseCan", saved.id!!, createdAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateCreatedAt(entityName: String, id: Long, createdAt: LocalDateTime) {
|
||||||
|
entityManager.createQuery("update $entityName e set e.createdAt = :createdAt where e.id = :id")
|
||||||
|
.setParameter("createdAt", createdAt)
|
||||||
|
.setParameter("id", id)
|
||||||
|
.executeUpdate()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,11 +9,13 @@ import kr.co.vividnext.sodalive.can.use.UseCanCalculate
|
|||||||
import kr.co.vividnext.sodalive.can.use.UseCanCalculateRepository
|
import kr.co.vividnext.sodalive.can.use.UseCanCalculateRepository
|
||||||
import kr.co.vividnext.sodalive.can.use.UseCanCalculateStatus
|
import kr.co.vividnext.sodalive.can.use.UseCanCalculateStatus
|
||||||
import kr.co.vividnext.sodalive.common.SodaException
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
|
import kr.co.vividnext.sodalive.extensions.convertLocalDateTime
|
||||||
import kr.co.vividnext.sodalive.i18n.LangContext
|
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||||
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
||||||
import kr.co.vividnext.sodalive.live.room.LiveRoom
|
import kr.co.vividnext.sodalive.live.room.LiveRoom
|
||||||
import kr.co.vividnext.sodalive.live.room.LiveRoomRepository
|
import kr.co.vividnext.sodalive.live.room.LiveRoomRepository
|
||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import org.apache.poi.xssf.usermodel.XSSFWorkbook
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
import org.junit.jupiter.api.Assertions.assertThrows
|
import org.junit.jupiter.api.Assertions.assertThrows
|
||||||
import org.junit.jupiter.api.Assertions.assertTrue
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
@@ -24,6 +26,8 @@ import org.mockito.Mockito.atLeastOnce
|
|||||||
import org.mockito.Mockito.mock
|
import org.mockito.Mockito.mock
|
||||||
import org.mockito.Mockito.verify
|
import org.mockito.Mockito.verify
|
||||||
import org.mockito.Mockito.`when`
|
import org.mockito.Mockito.`when`
|
||||||
|
import java.io.ByteArrayInputStream
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
import java.util.Optional
|
import java.util.Optional
|
||||||
|
|
||||||
class AdminCalculateServiceTest {
|
class AdminCalculateServiceTest {
|
||||||
@@ -45,6 +49,35 @@ class AdminCalculateServiceTest {
|
|||||||
langContext
|
langContext
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("크리에이터별 정산 엑셀은 이메일 대신 크리에이터 번호를 출력한다")
|
||||||
|
fun shouldWriteCreatorIdInsteadOfEmailToCreatorSettlementExcel() {
|
||||||
|
// given
|
||||||
|
val startDate = "2026-08-04".convertLocalDateTime()
|
||||||
|
val endDate = "2026-08-04".convertLocalDateTime(hour = 23, minute = 59, second = 59)
|
||||||
|
val queryData = GetCalculateByCreatorQueryData(
|
||||||
|
creatorId = 42L,
|
||||||
|
nickname = "creator",
|
||||||
|
totalCan = 100,
|
||||||
|
settlementRatio = 70
|
||||||
|
)
|
||||||
|
|
||||||
|
`when`(repository.getCalculateLiveByCreatorTotalCount(startDate, endDate)).thenReturn(1)
|
||||||
|
`when`(repository.getCalculateLiveByCreator(startDate, endDate, 0L, 1L)).thenReturn(listOf(queryData))
|
||||||
|
|
||||||
|
// when
|
||||||
|
val output = ByteArrayOutputStream()
|
||||||
|
adminCalculateService.downloadCalculateLiveByCreatorExcel("2026-08-04", "2026-08-04").writeTo(output)
|
||||||
|
|
||||||
|
// then
|
||||||
|
XSSFWorkbook(ByteArrayInputStream(output.toByteArray())).use { workbook ->
|
||||||
|
val sheet = workbook.getSheetAt(0)
|
||||||
|
assertEquals("크리에이터 번호", sheet.getRow(0).getCell(0).stringCellValue)
|
||||||
|
assertEquals(42.0, sheet.getRow(1).getCell(0).numericCellValue)
|
||||||
|
assertEquals("닉네임", sheet.getRow(0).getCell(1).stringCellValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("라이브 환불 성공 테스트")
|
@DisplayName("라이브 환불 성공 테스트")
|
||||||
fun shouldRefundLiveSuccessfully() {
|
fun shouldRefundLiveSuccessfully() {
|
||||||
|
|||||||
Reference in New Issue
Block a user