test #418
12
docs/20260421_라이브방무료여부응답추가.md
Normal file
12
docs/20260421_라이브방무료여부응답추가.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# 라이브방 무료 여부 응답 추가
|
||||
|
||||
- [x] `GetRoomInfoResponse`에 라이브방 무료 여부 필드 추가
|
||||
- [x] `GetRoomInfoResponse` 생성 경로에 무료 여부 매핑 반영
|
||||
- [x] 관련 검증 수행 및 결과 기록
|
||||
|
||||
## 검증 기록
|
||||
|
||||
### 1차 구현
|
||||
- 무엇을: `GetRoomInfoResponse`에 `isFreeRoom` 필드를 추가하고, `LiveRoomService`에서 `room.price == 0` 결과를 응답에 매핑했다.
|
||||
- 왜: 라이브방 정보 응답에서 클라이언트가 무료방 여부를 직접 판별할 수 있어야 하기 때문이다.
|
||||
- 어떻게: Kotlin `.kt` 파일은 현재 LSP 진단을 지원하지 않아 `lsp_diagnostics` 검증은 불가함을 확인했고, `./gradlew test`를 실행해 main/test 컴파일과 테스트를 함께 검증했으며 `BUILD SUCCESSFUL`을 확인했다. 로컬 실행 환경이 이 세션에 준비되지 않아 실제 API 호출 기반 수동 QA는 수행하지 못했다.
|
||||
244
docs/20260421_오리지널시리즈정산내역.md
Normal file
244
docs/20260421_오리지널시리즈정산내역.md
Normal file
@@ -0,0 +1,244 @@
|
||||
# 오리지널 시리즈 작품 화별 정산 내역 추가 작업 계획
|
||||
|
||||
- [x] 기존 관리자 정산 API 패턴(`/admin/calculate`, list/excel 쌍, `StreamingResponseBody`)을 유지하는 신규 패키지 구조를 확정한다.
|
||||
- [x] `kr.co.vividnext.sodalive.admin.calculate.originalSeries` 패키지에 오리지널 시리즈 소지 유저 조회 API를 추가한다.
|
||||
- [x] `kr.co.vividnext.sodalive.admin.calculate.originalSeries` 패키지에 오리지널 시리즈 정산 내역 조회 API를 추가한다.
|
||||
- [x] 정산 내역 조회 API가 `start_date`, `end_date`, `creator_id`를 받아 KST 입력 날짜를 UTC 조회 범위로 변환하도록 구현한다.
|
||||
- [x] 정산 내역 조회 쿼리가 `Series(isOriginal = true) -> SeriesContent -> AudioContent -> Order` 경로를 사용해 `Content.id`, `Order.type` 기준으로 그룹화되도록 구현한다.
|
||||
- [x] 정산 내역 결과에 시리즈 타이틀, 콘텐츠 타이틀, 가격, 대여/소장 여부, 판매 수, 합계(캔), 합계(포인트)를 포함하도록 DTO를 추가한다.
|
||||
- [x] 가격은 주문에 저장된 값을 기준으로 사용하되, 대여는 `ceil(price * 0.7)` 규칙이 반영된 값으로 노출되도록 검증한다.
|
||||
- [x] 오리지널 시리즈 정산 내역 엑셀 다운로드 API를 추가하고, 오리지널 시리즈를 소지한 유저별로 시트를 구성하도록 구현한다.
|
||||
- [x] 엑셀 다운로드는 각 유저 시트에 헤더 행과 정산 내역 행을 기록하고, 데이터가 없어도 헤더가 있는 시트를 유지하도록 구현한다.
|
||||
- [x] 신규 QueryRepository/Service/Controller에 대한 테스트를 먼저 작성하고 실패를 확인한 뒤 구현한다.
|
||||
- [x] 관련 테스트, 정적 진단, 수동 검증 결과를 확인하고 이 문서 하단 검증 기록에 남긴다.
|
||||
|
||||
## 구현 메모
|
||||
|
||||
- 오리지널 시리즈 여부는 `Series.isOriginal` 플래그를 기준으로 판단한다.
|
||||
- 소지 유저는 현재 코드베이스 기준 `Series.member`를 의미하는 것으로 해석한다.
|
||||
- 날짜 입력 파라미터는 `start_date`, `end_date`, `creator_id`로 받고, 날짜 값은 KST 기준 `00:00:00` / `23:59:59`로 해석한 뒤 `convertLocalDateTime()`으로 UTC `LocalDateTime`으로 변환한다.
|
||||
- 정산 내역 조회 API는 `creator_id`를 필수 필터로 사용하고, 엑셀 API는 전체 소지 유저를 순회해 시트를 생성하는 것으로 해석한다.
|
||||
|
||||
## API 명세
|
||||
|
||||
### 1. 오리지널 시리즈 소지 유저 조회
|
||||
|
||||
#### URI
|
||||
- `GET /admin/calculate/original-series/owners`
|
||||
|
||||
#### Request
|
||||
- Header
|
||||
- 인증: 관리자 권한 필요 (`hasRole('ADMIN')`)
|
||||
- Query Parameter
|
||||
- 없음
|
||||
- Body
|
||||
- 없음
|
||||
|
||||
#### Response
|
||||
- Content-Type: `application/json`
|
||||
- 응답 구조
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": null,
|
||||
"data": [
|
||||
{
|
||||
"creatorId": 1,
|
||||
"nickname": "owner-a"
|
||||
},
|
||||
{
|
||||
"creatorId": 2,
|
||||
"nickname": "owner-b"
|
||||
}
|
||||
],
|
||||
"errorProperty": null
|
||||
}
|
||||
```
|
||||
|
||||
#### Response Field
|
||||
- `success`: 성공 여부
|
||||
- `message`: 성공 메시지, 현재 구현에서는 `null`
|
||||
- `data`: 오리지널 시리즈 소지 유저 목록
|
||||
- `creatorId`: 정산 내역 조회에 사용할 크리에이터 ID
|
||||
- `nickname`: 관리자 화면에 노출할 닉네임
|
||||
- `errorProperty`: 에러 시 사용되는 필드, 성공 시 `null`
|
||||
|
||||
### 2. 오리지널 시리즈 정산 내역 조회
|
||||
|
||||
#### URI
|
||||
- `GET /admin/calculate/original-series/settlement-details`
|
||||
|
||||
#### Request
|
||||
- Header
|
||||
- 인증: 관리자 권한 필요 (`hasRole('ADMIN')`)
|
||||
- Query Parameter
|
||||
- `start_date`: 시작일, 형식 `yyyy-MM-dd`, KST 기준
|
||||
- `end_date`: 종료일, 형식 `yyyy-MM-dd`, KST 기준
|
||||
- `creator_id`: 오리지널 시리즈 소지 유저 ID
|
||||
- `page`: 페이지 번호, Spring `Pageable` 규칙 사용
|
||||
- `size`: 페이지 크기, Spring `Pageable` 규칙 사용
|
||||
- Body
|
||||
- 없음
|
||||
|
||||
#### Request Example
|
||||
|
||||
```text
|
||||
GET /admin/calculate/original-series/settlement-details?start_date=2026-04-01&end_date=2026-04-30&creator_id=1&page=0&size=20
|
||||
```
|
||||
|
||||
#### Response
|
||||
- Content-Type: `application/json`
|
||||
- 응답 구조
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": null,
|
||||
"data": {
|
||||
"totalCount": 2,
|
||||
"items": [
|
||||
{
|
||||
"seriesTitle": "오리지널 시리즈",
|
||||
"contentTitle": "1화",
|
||||
"price": 70,
|
||||
"orderType": "대여",
|
||||
"salesCount": 2,
|
||||
"totalCan": 130,
|
||||
"totalPoint": 100
|
||||
},
|
||||
{
|
||||
"seriesTitle": "오리지널 시리즈",
|
||||
"contentTitle": "1화",
|
||||
"price": 100,
|
||||
"orderType": "소장",
|
||||
"salesCount": 2,
|
||||
"totalCan": 200,
|
||||
"totalPoint": 25
|
||||
}
|
||||
]
|
||||
},
|
||||
"errorProperty": null
|
||||
}
|
||||
```
|
||||
|
||||
#### Response Field
|
||||
- `data.totalCount`: 조회 결과 전체 건수
|
||||
- `data.items`: 정산 내역 목록
|
||||
- `seriesTitle`: 시리즈 제목
|
||||
- `contentTitle`: 작품 화 제목
|
||||
- `price`: 표시 가격(캔)
|
||||
- `소장`: `audioContent.price`
|
||||
- `대여`: `ceil(audioContent.price * 0.7)`
|
||||
- `orderType`: `대여` 또는 `소장`
|
||||
- `salesCount`: 판매 건수
|
||||
- `totalCan`: 합계 캔
|
||||
- `totalPoint`: 합계 포인트
|
||||
|
||||
### 3. 오리지널 시리즈 정산 내역 엑셀 다운로드
|
||||
|
||||
#### URI
|
||||
- `GET /admin/calculate/original-series/settlement-details/excel`
|
||||
|
||||
#### Request
|
||||
- Header
|
||||
- 인증: 관리자 권한 필요 (`hasRole('ADMIN')`)
|
||||
- Query Parameter
|
||||
- `start_date`: 시작일, 형식 `yyyy-MM-dd`, KST 기준
|
||||
- `end_date`: 종료일, 형식 `yyyy-MM-dd`, KST 기준
|
||||
- Body
|
||||
- 없음
|
||||
|
||||
#### Request Example
|
||||
|
||||
```text
|
||||
GET /admin/calculate/original-series/settlement-details/excel?start_date=2026-04-01&end_date=2026-04-30
|
||||
```
|
||||
|
||||
#### Response
|
||||
- Content-Type: `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`
|
||||
- Header
|
||||
- `Content-Disposition: attachment; filename*=UTF-8''original-series-settlement-details.xlsx`
|
||||
- Body
|
||||
- `.xlsx` 바이너리 스트림
|
||||
|
||||
#### Excel 구성
|
||||
- 오리지널 시리즈 소지 유저별로 시트 1개 생성
|
||||
- 시트명 형식: `{nickname}`
|
||||
- 각 시트의 헤더 열 순서
|
||||
1. `시리즈 제목`
|
||||
2. `콘텐츠 제목`
|
||||
3. `가격(캔)`
|
||||
4. `구분`
|
||||
5. `판매 수`
|
||||
6. `합계(캔)`
|
||||
7. `합계(포인트)`
|
||||
- 해당 기간에 데이터가 없는 유저도 헤더만 있는 시트를 유지
|
||||
|
||||
## 클라이언트 기능 구현용 프롬프트
|
||||
|
||||
```text
|
||||
관리자 페이지에서 오리지널 시리즈 작품 화별 정산 내역을 조회하고 엑셀 다운로드하는 클라이언트 기능을 구현한다.
|
||||
|
||||
필수 API 계약은 아래와 같다.
|
||||
|
||||
1. 소지 유저 조회
|
||||
- GET /admin/calculate/original-series/owners
|
||||
- 응답은 ApiResponse<List<{ creatorId: number; nickname: string }>> 형태다.
|
||||
|
||||
2. 정산 내역 조회
|
||||
- GET /admin/calculate/original-series/settlement-details
|
||||
- query: start_date(yyyy-MM-dd), end_date(yyyy-MM-dd), creator_id(number), page(number), size(number)
|
||||
- 응답은 ApiResponse<{ totalCount: number; items: Array<{ seriesTitle: string; contentTitle: string; price: number; orderType: string; salesCount: number; totalCan: number; totalPoint: number }> }> 형태다.
|
||||
|
||||
3. 엑셀 다운로드
|
||||
- GET /admin/calculate/original-series/settlement-details/excel
|
||||
- query: start_date(yyyy-MM-dd), end_date(yyyy-MM-dd)
|
||||
- 응답은 xlsx 바이너리이며 파일명은 `original-series-settlement-details.xlsx` 이다.
|
||||
|
||||
구현 요구사항:
|
||||
- 화면 진입 시 owners API를 먼저 호출해 멤버 선택 드롭다운 데이터를 로드한다.
|
||||
- 사용자가 start_date, end_date, creator_id를 선택한 뒤 정산 내역 조회 API를 호출한다.
|
||||
- 목록 테이블에는 시리즈 제목, 콘텐츠 제목, 가격, 구분, 판매 수, 합계(캔), 합계(포인트)를 그대로 표시한다.
|
||||
- 페이지네이션은 page/size 기반으로 처리하고 totalCount를 사용해 총 페이지 수를 계산한다.
|
||||
- 엑셀 다운로드 버튼은 현재 선택된 start_date/end_date만 사용해 excel API를 호출한다.
|
||||
- JSON 응답은 항상 ApiResponse 래퍼의 success/data/message/errorProperty를 기준으로 처리한다.
|
||||
- success가 false이면 message를 우선 노출한다.
|
||||
- 날짜는 문자열 `yyyy-MM-dd` 형식으로 서버에 전달한다.
|
||||
|
||||
산출물 요구사항:
|
||||
- API 호출 함수
|
||||
- 타입 정의
|
||||
- 목록 조회 상태 관리
|
||||
- 엑셀 다운로드 처리
|
||||
- 에러 처리 로직
|
||||
|
||||
임의로 API 계약을 바꾸지 말고, 위 URI/Request/Response를 그대로 사용한다.
|
||||
```
|
||||
|
||||
## 검증 기록
|
||||
|
||||
### 1차 구현
|
||||
- 무엇을: `/admin/calculate/original-series/owners`, `/admin/calculate/original-series/settlement-details`, `/admin/calculate/original-series/settlement-details/excel` 3개 API와 전용 QueryRepository/Service/Controller/DTO를 추가했다.
|
||||
- 왜: 관리자 페이지에서 오리지널 시리즈 소지 유저를 먼저 조회하고, 선택한 유저의 작품 화별 정산 내역을 기간 기준으로 확인하며, 전체 소지 유저별 시트로 엑셀 다운로드할 수 있어야 했기 때문이다.
|
||||
- 어떻게:
|
||||
- `kr.co.vividnext.sodalive.admin.calculate.originalSeries` 패키지를 생성하고 `Series(isOriginal = true) -> SeriesContent -> AudioContent -> Order` 조인으로 정산 내역을 조회하도록 구현했다.
|
||||
- 정산 내역은 `Content.id`, `Order.type` 기준으로 그룹화하고 결과에 시리즈 제목, 콘텐츠 제목, 가격, 구분, 판매 수, 합계 캔, 합계 포인트를 담도록 구성했다.
|
||||
- 가격은 `audioContent.price`를 기준으로 계산하고, 대여는 응답 변환 시 `ceil(price * 0.7)`를 적용해 포인트 사용으로 `order.can`이 바뀐 주문도 동일한 표시 가격을 유지하도록 했다.
|
||||
- 엑셀은 `SXSSFWorkbook(100)` 기반 스트리밍으로 생성하고, 오리지널 시리즈 소지 유저별로 시트를 만들며 데이터가 없으면 헤더만 기록하도록 했다.
|
||||
- 테스트/검증 실행 결과:
|
||||
- `lsp_diagnostics` (신규 Kotlin 파일 경로) → Kotlin LSP 미설정으로 진단 불가
|
||||
- `./gradlew test --tests "*AdminOriginalSeriesCalculate*"` → 성공
|
||||
- `./gradlew ktlintCheck` → 성공
|
||||
- `./gradlew build` → 성공
|
||||
- `./gradlew test --tests "*AdminOriginalSeriesCalculateServiceTest.shouldCreateOneSheetPerOwnerForExcel"` → 성공
|
||||
|
||||
### 2차 문서화
|
||||
- 무엇을: 기존 작업 계획 문서에 오리지널 시리즈 정산 API 3종의 URI, Request, Response 상세 명세와 클라이언트 기능 구현용 프롬프트를 추가했다.
|
||||
- 왜: 클라이언트 기능 구현 시 서버 API 계약을 문서만 보고 그대로 사용할 수 있어야 하기 때문이다.
|
||||
- 어떻게:
|
||||
- `AdminOriginalSeriesCalculateController`, 응답 DTO, `ApiResponse` 구조를 기준으로 문서에 JSON 예시와 필드 설명을 정리했다.
|
||||
- 엑셀 다운로드 API는 바이너리 응답과 헤더 규격, 시트 구성 규칙을 함께 기록했다.
|
||||
- 클라이언트 개발자가 그대로 복사해 사용할 수 있도록 API 호출 순서와 상태 처리 조건을 포함한 프롬프트를 추가했다.
|
||||
- 실행 결과:
|
||||
- 수정 문서 재확인(`read`) → 성공
|
||||
@@ -0,0 +1,65 @@
|
||||
package kr.co.vividnext.sodalive.admin.calculate.originalSeries
|
||||
|
||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||
import org.springframework.data.domain.Pageable
|
||||
import org.springframework.http.HttpHeaders
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.security.access.prepost.PreAuthorize
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RequestParam
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody
|
||||
import java.net.URLEncoder
|
||||
import java.nio.charset.StandardCharsets
|
||||
|
||||
@RestController
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@RequestMapping("/admin/calculate")
|
||||
class AdminOriginalSeriesCalculateController(
|
||||
private val service: AdminOriginalSeriesCalculateService
|
||||
) {
|
||||
@GetMapping("/original-series/owners")
|
||||
fun getOriginalSeriesOwners() = ApiResponse.ok(service.getOriginalSeriesOwners())
|
||||
|
||||
@GetMapping("/original-series/settlement-details")
|
||||
fun getSettlementDetails(
|
||||
@RequestParam("start_date") startDateStr: String,
|
||||
@RequestParam("end_date") endDateStr: String,
|
||||
@RequestParam("creator_id") creatorId: Long,
|
||||
pageable: Pageable
|
||||
) = ApiResponse.ok(
|
||||
service.getSettlementDetails(
|
||||
startDateStr = startDateStr,
|
||||
endDateStr = endDateStr,
|
||||
creatorId = creatorId,
|
||||
offset = pageable.offset,
|
||||
limit = pageable.pageSize.toLong()
|
||||
)
|
||||
)
|
||||
|
||||
@GetMapping("/original-series/settlement-details/excel")
|
||||
fun downloadSettlementDetailsExcel(
|
||||
@RequestParam("start_date") startDateStr: String,
|
||||
@RequestParam("end_date") endDateStr: String
|
||||
): ResponseEntity<StreamingResponseBody> = createExcelResponse(
|
||||
fileName = "original-series-settlement-details.xlsx",
|
||||
response = service.downloadSettlementDetailsExcel(startDateStr, endDateStr)
|
||||
)
|
||||
|
||||
private fun createExcelResponse(
|
||||
fileName: String,
|
||||
response: StreamingResponseBody
|
||||
): ResponseEntity<StreamingResponseBody> {
|
||||
val encodedFileName = URLEncoder.encode(fileName, StandardCharsets.UTF_8.toString()).replace("+", "%20")
|
||||
val headers = HttpHeaders().apply {
|
||||
add(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''$encodedFileName")
|
||||
}
|
||||
|
||||
return ResponseEntity.ok()
|
||||
.headers(headers)
|
||||
.contentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))
|
||||
.body(response)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package kr.co.vividnext.sodalive.admin.calculate.originalSeries
|
||||
|
||||
import com.querydsl.core.types.dsl.BooleanExpression
|
||||
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||
import kr.co.vividnext.sodalive.content.QAudioContent.audioContent
|
||||
import kr.co.vividnext.sodalive.content.order.QOrder.order
|
||||
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeries.series
|
||||
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeriesContent.seriesContent
|
||||
import kr.co.vividnext.sodalive.member.QMember.member
|
||||
import org.springframework.stereotype.Repository
|
||||
import java.time.LocalDateTime
|
||||
|
||||
@Repository
|
||||
class AdminOriginalSeriesCalculateQueryRepository(
|
||||
private val queryFactory: JPAQueryFactory
|
||||
) {
|
||||
fun getOriginalSeriesOwners(): List<GetAdminOriginalSeriesOwnerResponse> {
|
||||
return queryFactory
|
||||
.select(
|
||||
QGetAdminOriginalSeriesOwnerResponse(
|
||||
member.id,
|
||||
member.nickname
|
||||
)
|
||||
)
|
||||
.from(series)
|
||||
.innerJoin(series.member, member)
|
||||
.where(
|
||||
series.isOriginal.isTrue
|
||||
.and(series.isActive.isTrue)
|
||||
.and(member.isActive.isTrue)
|
||||
)
|
||||
.groupBy(member.id, member.nickname)
|
||||
.orderBy(member.id.asc())
|
||||
.fetch()
|
||||
}
|
||||
|
||||
fun getSettlementDetailTotalCount(
|
||||
startDate: LocalDateTime,
|
||||
endDate: LocalDateTime,
|
||||
creatorId: Long
|
||||
): Int {
|
||||
return queryFactory
|
||||
.select(audioContent.id)
|
||||
.from(order)
|
||||
.innerJoin(order.audioContent, audioContent)
|
||||
.innerJoin(seriesContent).on(seriesContent.content.id.eq(audioContent.id))
|
||||
.innerJoin(seriesContent.series, series)
|
||||
.innerJoin(series.member, member)
|
||||
.where(baseWhereCondition(startDate, endDate, creatorId))
|
||||
.groupBy(
|
||||
series.id,
|
||||
series.title,
|
||||
audioContent.id,
|
||||
audioContent.title,
|
||||
order.type,
|
||||
audioContent.price
|
||||
)
|
||||
.fetch()
|
||||
.size
|
||||
}
|
||||
|
||||
fun getSettlementDetails(
|
||||
startDate: LocalDateTime,
|
||||
endDate: LocalDateTime,
|
||||
creatorId: Long,
|
||||
offset: Long,
|
||||
limit: Long
|
||||
): List<GetAdminOriginalSeriesSettlementDetailQueryData> {
|
||||
return queryFactory
|
||||
.select(
|
||||
QGetAdminOriginalSeriesSettlementDetailQueryData(
|
||||
series.title,
|
||||
audioContent.title,
|
||||
audioContent.price,
|
||||
order.type,
|
||||
order.id.count(),
|
||||
order.can.sum().coalesce(0),
|
||||
order.point.sum().coalesce(0)
|
||||
)
|
||||
)
|
||||
.from(order)
|
||||
.innerJoin(order.audioContent, audioContent)
|
||||
.innerJoin(seriesContent).on(seriesContent.content.id.eq(audioContent.id))
|
||||
.innerJoin(seriesContent.series, series)
|
||||
.innerJoin(series.member, member)
|
||||
.where(baseWhereCondition(startDate, endDate, creatorId))
|
||||
.groupBy(
|
||||
series.id,
|
||||
series.title,
|
||||
audioContent.id,
|
||||
audioContent.title,
|
||||
order.type,
|
||||
audioContent.price
|
||||
)
|
||||
.orderBy(series.id.asc(), audioContent.id.asc(), order.type.asc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
.fetch()
|
||||
}
|
||||
|
||||
private fun baseWhereCondition(
|
||||
startDate: LocalDateTime,
|
||||
endDate: LocalDateTime,
|
||||
creatorId: Long
|
||||
): BooleanExpression {
|
||||
return series.isOriginal.isTrue
|
||||
.and(series.isActive.isTrue)
|
||||
.and(member.isActive.isTrue)
|
||||
.and(member.id.eq(creatorId))
|
||||
.and(order.isActive.isTrue)
|
||||
.and(order.createdAt.goe(startDate))
|
||||
.and(order.createdAt.loe(endDate))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package kr.co.vividnext.sodalive.admin.calculate.originalSeries
|
||||
|
||||
import kr.co.vividnext.sodalive.extensions.convertLocalDateTime
|
||||
import org.apache.poi.ss.usermodel.Sheet
|
||||
import org.apache.poi.ss.util.WorkbookUtil
|
||||
import org.apache.poi.xssf.streaming.SXSSFWorkbook
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody
|
||||
import java.time.LocalDateTime
|
||||
|
||||
@Service
|
||||
class AdminOriginalSeriesCalculateService(
|
||||
private val repository: AdminOriginalSeriesCalculateQueryRepository
|
||||
) {
|
||||
@Transactional(readOnly = true)
|
||||
fun getOriginalSeriesOwners(): List<GetAdminOriginalSeriesOwnerResponse> {
|
||||
return repository.getOriginalSeriesOwners()
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun getSettlementDetails(
|
||||
startDateStr: String,
|
||||
endDateStr: String,
|
||||
creatorId: Long,
|
||||
offset: Long,
|
||||
limit: Long
|
||||
): GetAdminOriginalSeriesSettlementDetailListResponse {
|
||||
val (startDate, endDate) = toDateRange(startDateStr, endDateStr)
|
||||
val totalCount = repository.getSettlementDetailTotalCount(startDate, endDate, creatorId)
|
||||
val items = repository
|
||||
.getSettlementDetails(startDate, endDate, creatorId, offset, limit)
|
||||
.map { it.toResponse() }
|
||||
|
||||
return GetAdminOriginalSeriesSettlementDetailListResponse(totalCount, items)
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun downloadSettlementDetailsExcel(startDateStr: String, endDateStr: String): StreamingResponseBody {
|
||||
val (startDate, endDate) = toDateRange(startDateStr, endDateStr)
|
||||
val owners = repository.getOriginalSeriesOwners()
|
||||
|
||||
return StreamingResponseBody { outputStream ->
|
||||
val workbook = SXSSFWorkbook(100)
|
||||
try {
|
||||
if (owners.isNotEmpty()) {
|
||||
owners.forEach { owner ->
|
||||
val sheet = workbook.createSheet(toSheetName(owner))
|
||||
writeHeaders(sheet)
|
||||
|
||||
val totalCount = repository.getSettlementDetailTotalCount(startDate, endDate, owner.creatorId)
|
||||
val items = if (totalCount == 0) {
|
||||
emptyList()
|
||||
} else {
|
||||
repository.getSettlementDetails(startDate, endDate, owner.creatorId, 0L, totalCount.toLong())
|
||||
.map { it.toResponse() }
|
||||
}
|
||||
|
||||
writeRows(sheet, items)
|
||||
}
|
||||
}
|
||||
|
||||
workbook.write(outputStream)
|
||||
outputStream.flush()
|
||||
} finally {
|
||||
workbook.dispose()
|
||||
workbook.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun writeHeaders(sheet: Sheet) {
|
||||
val headerRow = sheet.createRow(0)
|
||||
EXCEL_HEADERS.forEachIndexed { index, value ->
|
||||
headerRow.createCell(index).setCellValue(value)
|
||||
}
|
||||
}
|
||||
|
||||
private fun writeRows(sheet: Sheet, items: List<GetAdminOriginalSeriesSettlementDetailResponse>) {
|
||||
items.forEachIndexed { index, item ->
|
||||
val row = sheet.createRow(index + 1)
|
||||
row.createCell(0).setCellValue(item.seriesTitle)
|
||||
row.createCell(1).setCellValue(item.contentTitle)
|
||||
row.createCell(2).setCellValue(item.price.toDouble())
|
||||
row.createCell(3).setCellValue(item.orderType)
|
||||
row.createCell(4).setCellValue(item.salesCount.toDouble())
|
||||
row.createCell(5).setCellValue(item.totalCan.toDouble())
|
||||
row.createCell(6).setCellValue(item.totalPoint.toDouble())
|
||||
}
|
||||
}
|
||||
|
||||
private fun toSheetName(owner: GetAdminOriginalSeriesOwnerResponse): String {
|
||||
return WorkbookUtil.createSafeSheetName(owner.nickname)
|
||||
}
|
||||
|
||||
private fun toDateRange(startDateStr: String, endDateStr: String): Pair<LocalDateTime, LocalDateTime> {
|
||||
val startDate = startDateStr.convertLocalDateTime()
|
||||
val endDate = endDateStr.convertLocalDateTime(hour = 23, minute = 59, second = 59)
|
||||
|
||||
return startDate to endDate
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val EXCEL_HEADERS = listOf(
|
||||
"시리즈 제목",
|
||||
"콘텐츠 제목",
|
||||
"가격(캔)",
|
||||
"구분",
|
||||
"판매 수",
|
||||
"합계(캔)",
|
||||
"합계(포인트)"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package kr.co.vividnext.sodalive.admin.calculate.originalSeries
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
import com.querydsl.core.annotations.QueryProjection
|
||||
|
||||
data class GetAdminOriginalSeriesOwnerResponse @QueryProjection constructor(
|
||||
@JsonProperty("creatorId") val creatorId: Long,
|
||||
@JsonProperty("nickname") val nickname: String
|
||||
)
|
||||
@@ -0,0 +1,27 @@
|
||||
package kr.co.vividnext.sodalive.admin.calculate.originalSeries
|
||||
|
||||
import com.querydsl.core.annotations.QueryProjection
|
||||
import kr.co.vividnext.sodalive.content.order.OrderType
|
||||
import kotlin.math.ceil
|
||||
|
||||
data class GetAdminOriginalSeriesSettlementDetailQueryData @QueryProjection constructor(
|
||||
val seriesTitle: String,
|
||||
val contentTitle: String,
|
||||
val basePrice: Int,
|
||||
val orderType: OrderType,
|
||||
val salesCount: Long,
|
||||
val totalCan: Int?,
|
||||
val totalPoint: Int?
|
||||
) {
|
||||
fun toResponse(): GetAdminOriginalSeriesSettlementDetailResponse {
|
||||
return GetAdminOriginalSeriesSettlementDetailResponse(
|
||||
seriesTitle = seriesTitle,
|
||||
contentTitle = contentTitle,
|
||||
price = if (orderType == OrderType.RENTAL) ceil(basePrice * 0.7).toInt() else basePrice,
|
||||
orderType = if (orderType == OrderType.RENTAL) "대여" else "소장",
|
||||
salesCount = salesCount.toInt(),
|
||||
totalCan = totalCan ?: 0,
|
||||
totalPoint = totalPoint ?: 0
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package kr.co.vividnext.sodalive.admin.calculate.originalSeries
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
|
||||
data class GetAdminOriginalSeriesSettlementDetailResponse(
|
||||
@JsonProperty("seriesTitle") val seriesTitle: String,
|
||||
@JsonProperty("contentTitle") val contentTitle: String,
|
||||
@JsonProperty("price") val price: Int,
|
||||
@JsonProperty("orderType") val orderType: String,
|
||||
@JsonProperty("salesCount") val salesCount: Int,
|
||||
@JsonProperty("totalCan") val totalCan: Int,
|
||||
@JsonProperty("totalPoint") val totalPoint: Int
|
||||
)
|
||||
|
||||
data class GetAdminOriginalSeriesSettlementDetailListResponse(
|
||||
@JsonProperty("totalCount") val totalCount: Int,
|
||||
@JsonProperty("items") val items: List<GetAdminOriginalSeriesSettlementDetailResponse>
|
||||
)
|
||||
@@ -1076,6 +1076,7 @@ class LiveRoomService(
|
||||
donationRankingTop3UserIds = donationRankingTop3UserIds,
|
||||
menuPan = menuPan?.menu ?: "",
|
||||
creatorLanguageCode = creatorLanguageCode,
|
||||
isFreeRoom = room.price == 0,
|
||||
isPrivateRoom = room.type == LiveRoomType.PRIVATE,
|
||||
password = room.password,
|
||||
isActiveRoulette = isActiveRoulette,
|
||||
|
||||
@@ -23,6 +23,7 @@ data class GetRoomInfoResponse(
|
||||
val donationRankingTop3UserIds: List<Long>,
|
||||
val menuPan: String,
|
||||
val creatorLanguageCode: String?,
|
||||
val isFreeRoom: Boolean,
|
||||
val isPrivateRoom: Boolean = false,
|
||||
val password: String? = null,
|
||||
val isActiveRoulette: Boolean = false,
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
package kr.co.vividnext.sodalive.admin.calculate.originalSeries
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito
|
||||
import org.springframework.data.domain.PageRequest
|
||||
import org.springframework.http.HttpHeaders
|
||||
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody
|
||||
|
||||
class AdminOriginalSeriesCalculateControllerTest {
|
||||
private lateinit var service: AdminOriginalSeriesCalculateService
|
||||
private lateinit var controller: AdminOriginalSeriesCalculateController
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
service = Mockito.mock(AdminOriginalSeriesCalculateService::class.java)
|
||||
controller = AdminOriginalSeriesCalculateController(service)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("관리자 컨트롤러는 오리지널 시리즈 소지 유저 목록을 반환한다")
|
||||
fun shouldReturnOriginalSeriesOwners() {
|
||||
val owners = listOf(GetAdminOriginalSeriesOwnerResponse(creatorId = 1L, nickname = "owner-a"))
|
||||
Mockito.`when`(service.getOriginalSeriesOwners()).thenReturn(owners)
|
||||
|
||||
val response = controller.getOriginalSeriesOwners()
|
||||
|
||||
assertEquals(true, response.success)
|
||||
assertEquals(1, response.data!!.size)
|
||||
assertEquals(1L, response.data!![0].creatorId)
|
||||
assertEquals("owner-a", response.data!![0].nickname)
|
||||
Mockito.verify(service).getOriginalSeriesOwners()
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("관리자 컨트롤러는 정산 내역 조회 파라미터를 서비스로 전달한다")
|
||||
fun shouldForwardSettlementDetailParamsToService() {
|
||||
val detailResponse = GetAdminOriginalSeriesSettlementDetailListResponse(
|
||||
totalCount = 1,
|
||||
items = listOf(
|
||||
GetAdminOriginalSeriesSettlementDetailResponse(
|
||||
seriesTitle = "series",
|
||||
contentTitle = "episode",
|
||||
price = 70,
|
||||
orderType = "대여",
|
||||
salesCount = 2,
|
||||
totalCan = 140,
|
||||
totalPoint = 10
|
||||
)
|
||||
)
|
||||
)
|
||||
Mockito.`when`(
|
||||
service.getSettlementDetails(
|
||||
startDateStr = "2026-04-01",
|
||||
endDateStr = "2026-04-30",
|
||||
creatorId = 5L,
|
||||
offset = 10L,
|
||||
limit = 10L
|
||||
)
|
||||
).thenReturn(detailResponse)
|
||||
|
||||
val response = controller.getSettlementDetails(
|
||||
startDateStr = "2026-04-01",
|
||||
endDateStr = "2026-04-30",
|
||||
creatorId = 5L,
|
||||
pageable = PageRequest.of(1, 10)
|
||||
)
|
||||
|
||||
assertEquals(true, response.success)
|
||||
assertEquals(1, response.data!!.totalCount)
|
||||
assertEquals("series", response.data!!.items[0].seriesTitle)
|
||||
Mockito.verify(service).getSettlementDetails(
|
||||
startDateStr = "2026-04-01",
|
||||
endDateStr = "2026-04-30",
|
||||
creatorId = 5L,
|
||||
offset = 10L,
|
||||
limit = 10L
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("정산 내역 조회 API는 snake_case 파라미터를 사용한다")
|
||||
fun shouldUseSnakeCaseQueryParameterNames() {
|
||||
val method = AdminOriginalSeriesCalculateController::class.java
|
||||
.getDeclaredMethod(
|
||||
"getSettlementDetails",
|
||||
String::class.java,
|
||||
String::class.java,
|
||||
java.lang.Long.TYPE,
|
||||
org.springframework.data.domain.Pageable::class.java
|
||||
)
|
||||
val parameters = method.parameters
|
||||
val requestParamClass = org.springframework.web.bind.annotation.RequestParam::class.java
|
||||
|
||||
assertEquals("start_date", parameters[0].getAnnotation(requestParamClass).value)
|
||||
assertEquals("end_date", parameters[1].getAnnotation(requestParamClass).value)
|
||||
assertEquals("creator_id", parameters[2].getAnnotation(requestParamClass).value)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("정산 엑셀 다운로드 API는 snake_case 파라미터를 사용한다")
|
||||
fun shouldUseSnakeCaseQueryParameterNamesForExcel() {
|
||||
val method = AdminOriginalSeriesCalculateController::class.java
|
||||
.getDeclaredMethod(
|
||||
"downloadSettlementDetailsExcel",
|
||||
String::class.java,
|
||||
String::class.java
|
||||
)
|
||||
val parameters = method.parameters
|
||||
val requestParamClass = org.springframework.web.bind.annotation.RequestParam::class.java
|
||||
|
||||
assertEquals("start_date", parameters[0].getAnnotation(requestParamClass).value)
|
||||
assertEquals("end_date", parameters[1].getAnnotation(requestParamClass).value)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("관리자 컨트롤러는 오리지널 시리즈 정산 엑셀을 다운로드한다")
|
||||
fun shouldDownloadOriginalSeriesSettlementExcel() {
|
||||
Mockito.`when`(
|
||||
service.downloadSettlementDetailsExcel(
|
||||
startDateStr = "2026-04-01",
|
||||
endDateStr = "2026-04-30"
|
||||
)
|
||||
).thenReturn(StreamingResponseBody { outputStream -> outputStream.write(byteArrayOf(1, 2, 3)) })
|
||||
|
||||
val response = controller.downloadSettlementDetailsExcel(
|
||||
startDateStr = "2026-04-01",
|
||||
endDateStr = "2026-04-30"
|
||||
)
|
||||
|
||||
assertEquals(200, response.statusCode.value())
|
||||
val contentDispositionHeader = response.headers.getFirst(HttpHeaders.CONTENT_DISPOSITION)
|
||||
assertNotNull(contentDispositionHeader)
|
||||
assertEquals(true, contentDispositionHeader?.contains("attachment; filename*="))
|
||||
assertEquals(
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
response.headers.contentType.toString()
|
||||
)
|
||||
assertNotNull(response.body)
|
||||
assertEquals(true, response.body is StreamingResponseBody)
|
||||
|
||||
Mockito.verify(service).downloadSettlementDetailsExcel(
|
||||
startDateStr = "2026-04-01",
|
||||
endDateStr = "2026-04-30"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
package kr.co.vividnext.sodalive.admin.calculate.originalSeries
|
||||
|
||||
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||
import kr.co.vividnext.sodalive.admin.content.series.genre.SeriesGenre
|
||||
import kr.co.vividnext.sodalive.configs.QueryDslConfig
|
||||
import kr.co.vividnext.sodalive.content.AudioContent
|
||||
import kr.co.vividnext.sodalive.content.order.Order
|
||||
import kr.co.vividnext.sodalive.content.order.OrderType
|
||||
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
|
||||
import kr.co.vividnext.sodalive.creator.admin.content.series.Series
|
||||
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesContent
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
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
|
||||
@Import(QueryDslConfig::class)
|
||||
class AdminOriginalSeriesCalculateQueryRepositoryTest @Autowired constructor(
|
||||
private val queryFactory: JPAQueryFactory,
|
||||
private val entityManager: EntityManager
|
||||
) {
|
||||
private lateinit var repository: AdminOriginalSeriesCalculateQueryRepository
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
repository = AdminOriginalSeriesCalculateQueryRepository(queryFactory)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("오리지널 시리즈 소지 유저 조회는 중복 없이 오리지널 시리즈 보유자만 반환한다")
|
||||
fun shouldGetDistinctOwnersOfOriginalSeries() {
|
||||
val genre = saveSeriesGenre()
|
||||
val ownerA = saveMember("owner-a", MemberRole.CREATOR)
|
||||
val ownerB = saveMember("owner-b", MemberRole.CREATOR)
|
||||
val inactiveOwner = saveMember("owner-c", MemberRole.CREATOR, isActive = false)
|
||||
|
||||
saveSeries("series-a1", ownerA, genre, isOriginal = true)
|
||||
saveSeries("series-a2", ownerA, genre, isOriginal = true)
|
||||
saveSeries("series-b1", ownerB, genre, isOriginal = true)
|
||||
saveSeries("series-c1", inactiveOwner, genre, isOriginal = true)
|
||||
saveSeries("series-non-original", ownerB, genre, isOriginal = false)
|
||||
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
|
||||
val result = repository.getOriginalSeriesOwners()
|
||||
|
||||
assertEquals(2, result.size)
|
||||
assertEquals(ownerA.id, result[0].creatorId)
|
||||
assertEquals(ownerB.id, result[1].creatorId)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("오리지널 시리즈 정산 내역 조회는 콘텐츠와 주문 타입 기준으로 그룹화한다")
|
||||
fun shouldAggregateSettlementDetailsByContentAndOrderType() {
|
||||
val genre = saveSeriesGenre()
|
||||
val theme = saveTheme()
|
||||
val owner = saveMember("owner-a", MemberRole.CREATOR)
|
||||
val otherOwner = saveMember("owner-b", MemberRole.CREATOR)
|
||||
val buyerA = saveMember("buyer-a", MemberRole.USER)
|
||||
val buyerB = saveMember("buyer-b", MemberRole.USER)
|
||||
val otherBuyer = saveMember("buyer-c", MemberRole.USER)
|
||||
|
||||
val originalSeries = saveSeries("오리지널 시리즈", owner, genre, isOriginal = true)
|
||||
val otherSeries = saveSeries("다른 시리즈", otherOwner, genre, isOriginal = true)
|
||||
val nonOriginalSeries = saveSeries("비오리지널 시리즈", owner, genre, isOriginal = false)
|
||||
|
||||
val episode = saveContent("1화", 100, owner, theme)
|
||||
val otherEpisode = saveContent("2화", 120, otherOwner, theme)
|
||||
val excludedEpisode = saveContent("3화", 150, owner, theme)
|
||||
|
||||
saveSeriesContent(originalSeries, episode)
|
||||
saveSeriesContent(otherSeries, otherEpisode)
|
||||
saveSeriesContent(nonOriginalSeries, excludedEpisode)
|
||||
|
||||
val keepOrder1 = saveOrder(
|
||||
OrderType.KEEP,
|
||||
episode,
|
||||
buyerA,
|
||||
owner,
|
||||
point = 20,
|
||||
createdAt = LocalDateTime.of(2026, 4, 10, 10, 0, 0)
|
||||
)
|
||||
val keepOrder2 = saveOrder(
|
||||
OrderType.KEEP,
|
||||
episode,
|
||||
buyerB,
|
||||
owner,
|
||||
point = 5,
|
||||
createdAt = LocalDateTime.of(2026, 4, 11, 10, 0, 0)
|
||||
)
|
||||
val rentalOrder = saveOrder(
|
||||
OrderType.RENTAL,
|
||||
episode,
|
||||
buyerA,
|
||||
owner,
|
||||
point = 0,
|
||||
createdAt = LocalDateTime.of(2026, 4, 12, 10, 0, 0)
|
||||
)
|
||||
val discountedRentalOrder = saveOrder(
|
||||
OrderType.RENTAL,
|
||||
episode,
|
||||
buyerB,
|
||||
owner,
|
||||
point = 100,
|
||||
createdAt = LocalDateTime.of(2026, 4, 15, 10, 0, 0),
|
||||
canOverride = 60
|
||||
)
|
||||
|
||||
saveOrder(
|
||||
OrderType.RENTAL,
|
||||
episode,
|
||||
buyerA,
|
||||
owner,
|
||||
point = 0,
|
||||
createdAt = LocalDateTime.of(2026, 5, 1, 0, 0, 0)
|
||||
)
|
||||
saveOrder(
|
||||
OrderType.KEEP,
|
||||
otherEpisode,
|
||||
otherBuyer,
|
||||
otherOwner,
|
||||
point = 0,
|
||||
createdAt = LocalDateTime.of(2026, 4, 13, 10, 0, 0)
|
||||
)
|
||||
saveOrder(
|
||||
OrderType.KEEP,
|
||||
excludedEpisode,
|
||||
buyerA,
|
||||
owner,
|
||||
point = 0,
|
||||
createdAt = LocalDateTime.of(2026, 4, 14, 10, 0, 0)
|
||||
)
|
||||
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
|
||||
val startDate = LocalDateTime.of(2026, 4, 1, 0, 0, 0)
|
||||
val endDate = LocalDateTime.of(2026, 4, 30, 23, 59, 59)
|
||||
|
||||
val totalCount = repository.getSettlementDetailTotalCount(startDate, endDate, owner.id!!)
|
||||
val result = repository.getSettlementDetails(startDate, endDate, owner.id!!, 0L, 20L)
|
||||
|
||||
assertEquals(2, totalCount)
|
||||
assertEquals(2, result.size)
|
||||
|
||||
val keepRow = result.first { it.orderType == OrderType.KEEP }
|
||||
assertEquals("오리지널 시리즈", keepRow.seriesTitle)
|
||||
assertEquals("1화", keepRow.contentTitle)
|
||||
assertEquals(100, keepRow.basePrice)
|
||||
assertEquals(2L, keepRow.salesCount)
|
||||
assertEquals(200, keepRow.totalCan)
|
||||
assertEquals(25, keepRow.totalPoint)
|
||||
|
||||
val rentalRow = result.first { it.orderType == OrderType.RENTAL }
|
||||
assertEquals(100, rentalRow.basePrice)
|
||||
assertEquals(2L, rentalRow.salesCount)
|
||||
assertEquals(130, rentalRow.totalCan)
|
||||
assertEquals(100, rentalRow.totalPoint)
|
||||
assertEquals(70, rentalRow.toResponse().price)
|
||||
|
||||
assertEquals(100, keepOrder1.can)
|
||||
assertEquals(100, keepOrder2.can)
|
||||
assertEquals(70, rentalOrder.can)
|
||||
assertEquals(60, discountedRentalOrder.can)
|
||||
}
|
||||
|
||||
private fun saveMember(nickname: String, role: MemberRole, isActive: Boolean = true): Member {
|
||||
val member = Member(
|
||||
email = "$nickname@test.com",
|
||||
password = "password",
|
||||
nickname = nickname,
|
||||
role = role,
|
||||
isActive = isActive
|
||||
)
|
||||
entityManager.persist(member)
|
||||
return member
|
||||
}
|
||||
|
||||
private fun saveSeriesGenre(): SeriesGenre {
|
||||
val genre = SeriesGenre(genre = "genre")
|
||||
entityManager.persist(genre)
|
||||
return genre
|
||||
}
|
||||
|
||||
private fun saveTheme(): AudioContentTheme {
|
||||
val theme = AudioContentTheme(theme = "theme", image = "theme.png")
|
||||
entityManager.persist(theme)
|
||||
return theme
|
||||
}
|
||||
|
||||
private fun saveSeries(title: String, owner: Member, genre: SeriesGenre, isOriginal: Boolean): Series {
|
||||
val series = Series(
|
||||
title = title,
|
||||
introduction = "introduction",
|
||||
languageCode = "ko",
|
||||
isOriginal = isOriginal
|
||||
)
|
||||
series.member = owner
|
||||
series.genre = genre
|
||||
entityManager.persist(series)
|
||||
return series
|
||||
}
|
||||
|
||||
private fun saveContent(title: String, price: Int, creator: Member, theme: AudioContentTheme): AudioContent {
|
||||
val content = AudioContent(
|
||||
title = title,
|
||||
detail = "detail",
|
||||
languageCode = "ko",
|
||||
price = price
|
||||
)
|
||||
content.theme = theme
|
||||
content.member = creator
|
||||
content.isActive = true
|
||||
entityManager.persist(content)
|
||||
return content
|
||||
}
|
||||
|
||||
private fun saveSeriesContent(series: Series, content: AudioContent): SeriesContent {
|
||||
val seriesContent = SeriesContent()
|
||||
seriesContent.series = series
|
||||
seriesContent.content = content
|
||||
entityManager.persist(seriesContent)
|
||||
return seriesContent
|
||||
}
|
||||
|
||||
private fun saveOrder(
|
||||
type: OrderType,
|
||||
content: AudioContent,
|
||||
buyer: Member,
|
||||
creator: Member,
|
||||
point: Int,
|
||||
createdAt: LocalDateTime,
|
||||
canOverride: Int? = null
|
||||
): Order {
|
||||
val order = Order(type = type)
|
||||
order.member = buyer
|
||||
order.creator = creator
|
||||
order.audioContent = content
|
||||
order.point = point
|
||||
entityManager.persist(order)
|
||||
entityManager.flush()
|
||||
val paidCan = canOverride ?: order.can
|
||||
entityManager.createQuery(
|
||||
"update Order o set o.createdAt = :createdAt, o.can = :can, o.point = :point where o.id = :id"
|
||||
)
|
||||
.setParameter("createdAt", createdAt)
|
||||
.setParameter("can", paidCan)
|
||||
.setParameter("point", point)
|
||||
.setParameter("id", order.id)
|
||||
.executeUpdate()
|
||||
entityManager.clear()
|
||||
|
||||
return entityManager.find(Order::class.java, order.id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package kr.co.vividnext.sodalive.admin.calculate.originalSeries
|
||||
|
||||
import kr.co.vividnext.sodalive.content.order.OrderType
|
||||
import kr.co.vividnext.sodalive.extensions.convertLocalDateTime
|
||||
import org.apache.poi.xssf.usermodel.XSSFWorkbook
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
|
||||
class AdminOriginalSeriesCalculateServiceTest {
|
||||
private lateinit var repository: AdminOriginalSeriesCalculateQueryRepository
|
||||
private lateinit var service: AdminOriginalSeriesCalculateService
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
repository = Mockito.mock(AdminOriginalSeriesCalculateQueryRepository::class.java)
|
||||
service = AdminOriginalSeriesCalculateService(repository)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("오리지널 시리즈 소지 유저 목록 조회는 리포지토리 결과를 반환한다")
|
||||
fun shouldReturnOriginalSeriesOwners() {
|
||||
val owners = listOf(GetAdminOriginalSeriesOwnerResponse(creatorId = 1L, nickname = "owner-a"))
|
||||
Mockito.`when`(repository.getOriginalSeriesOwners()).thenReturn(owners)
|
||||
|
||||
val result = service.getOriginalSeriesOwners()
|
||||
|
||||
assertEquals(1, result.size)
|
||||
assertEquals(1L, result[0].creatorId)
|
||||
Mockito.verify(repository).getOriginalSeriesOwners()
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("정산 내역 조회는 날짜 범위를 UTC로 변환하고 페이지 응답을 반환한다")
|
||||
fun shouldConvertDateRangeAndReturnSettlementDetails() {
|
||||
val queryData = GetAdminOriginalSeriesSettlementDetailQueryData(
|
||||
seriesTitle = "오리지널 시리즈",
|
||||
contentTitle = "1화",
|
||||
basePrice = 100,
|
||||
orderType = OrderType.RENTAL,
|
||||
salesCount = 2L,
|
||||
totalCan = 140,
|
||||
totalPoint = 10
|
||||
)
|
||||
Mockito.`when`(
|
||||
repository.getSettlementDetailTotalCount(
|
||||
"2026-04-01".convertLocalDateTime(),
|
||||
"2026-04-30".convertLocalDateTime(hour = 23, minute = 59, second = 59),
|
||||
9L
|
||||
)
|
||||
).thenReturn(1)
|
||||
Mockito.`when`(
|
||||
repository.getSettlementDetails(
|
||||
"2026-04-01".convertLocalDateTime(),
|
||||
"2026-04-30".convertLocalDateTime(hour = 23, minute = 59, second = 59),
|
||||
9L,
|
||||
0L,
|
||||
20L
|
||||
)
|
||||
).thenReturn(listOf(queryData))
|
||||
|
||||
val result = service.getSettlementDetails(
|
||||
startDateStr = "2026-04-01",
|
||||
endDateStr = "2026-04-30",
|
||||
creatorId = 9L,
|
||||
offset = 0L,
|
||||
limit = 20L
|
||||
)
|
||||
|
||||
assertEquals(1, result.totalCount)
|
||||
assertEquals(1, result.items.size)
|
||||
assertEquals(70, result.items[0].price)
|
||||
assertEquals("대여", result.items[0].orderType)
|
||||
assertEquals(140, result.items[0].totalCan)
|
||||
|
||||
Mockito.verify(repository).getSettlementDetailTotalCount(
|
||||
"2026-04-01".convertLocalDateTime(),
|
||||
"2026-04-30".convertLocalDateTime(hour = 23, minute = 59, second = 59),
|
||||
9L
|
||||
)
|
||||
Mockito.verify(repository).getSettlementDetails(
|
||||
"2026-04-01".convertLocalDateTime(),
|
||||
"2026-04-30".convertLocalDateTime(hour = 23, minute = 59, second = 59),
|
||||
9L,
|
||||
0L,
|
||||
20L
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("정산 엑셀 다운로드는 소지 유저별 시트를 생성한다")
|
||||
fun shouldCreateOneSheetPerOwnerForExcel() {
|
||||
val owners = listOf(
|
||||
GetAdminOriginalSeriesOwnerResponse(creatorId = 1L, nickname = "owner-a"),
|
||||
GetAdminOriginalSeriesOwnerResponse(creatorId = 2L, nickname = "owner-b")
|
||||
)
|
||||
Mockito.`when`(repository.getOriginalSeriesOwners()).thenReturn(owners)
|
||||
Mockito.`when`(
|
||||
repository.getSettlementDetailTotalCount(
|
||||
"2026-04-01".convertLocalDateTime(),
|
||||
"2026-04-30".convertLocalDateTime(hour = 23, minute = 59, second = 59),
|
||||
1L
|
||||
)
|
||||
).thenReturn(1)
|
||||
Mockito.`when`(
|
||||
repository.getSettlementDetailTotalCount(
|
||||
"2026-04-01".convertLocalDateTime(),
|
||||
"2026-04-30".convertLocalDateTime(hour = 23, minute = 59, second = 59),
|
||||
2L
|
||||
)
|
||||
).thenReturn(0)
|
||||
Mockito.`when`(
|
||||
repository.getSettlementDetails(
|
||||
"2026-04-01".convertLocalDateTime(),
|
||||
"2026-04-30".convertLocalDateTime(hour = 23, minute = 59, second = 59),
|
||||
1L,
|
||||
0L,
|
||||
1L
|
||||
)
|
||||
).thenReturn(
|
||||
listOf(
|
||||
GetAdminOriginalSeriesSettlementDetailQueryData(
|
||||
seriesTitle = "오리지널 시리즈",
|
||||
contentTitle = "1화",
|
||||
basePrice = 100,
|
||||
orderType = OrderType.KEEP,
|
||||
salesCount = 2L,
|
||||
totalCan = 200,
|
||||
totalPoint = 30
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val response = service.downloadSettlementDetailsExcel(
|
||||
startDateStr = "2026-04-01",
|
||||
endDateStr = "2026-04-30"
|
||||
)
|
||||
val outputStream = ByteArrayOutputStream()
|
||||
response.writeTo(outputStream)
|
||||
|
||||
assertTrue(outputStream.toByteArray().isNotEmpty())
|
||||
|
||||
XSSFWorkbook(ByteArrayInputStream(outputStream.toByteArray())).use { workbook ->
|
||||
assertEquals(2, workbook.numberOfSheets)
|
||||
assertEquals("owner-a", workbook.getSheetAt(0).sheetName)
|
||||
assertEquals("owner-b", workbook.getSheetAt(1).sheetName)
|
||||
assertEquals("시리즈 제목", workbook.getSheetAt(0).getRow(0).getCell(0).stringCellValue)
|
||||
assertEquals("오리지널 시리즈", workbook.getSheetAt(0).getRow(1).getCell(0).stringCellValue)
|
||||
assertEquals("시리즈 제목", workbook.getSheetAt(1).getRow(0).getCell(0).stringCellValue)
|
||||
assertEquals(1, workbook.getSheetAt(1).physicalNumberOfRows)
|
||||
}
|
||||
|
||||
Mockito.verify(repository).getOriginalSeriesOwners()
|
||||
Mockito.verify(repository).getSettlementDetails(
|
||||
"2026-04-01".convertLocalDateTime(),
|
||||
"2026-04-30".convertLocalDateTime(hour = 23, minute = 59, second = 59),
|
||||
1L,
|
||||
0L,
|
||||
1L
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("소지 유저가 없으면 엑셀에 시트를 생성하지 않는다")
|
||||
fun shouldCreateWorkbookWithoutSheetsWhenNoOwners() {
|
||||
Mockito.`when`(repository.getOriginalSeriesOwners()).thenReturn(emptyList())
|
||||
|
||||
val response = service.downloadSettlementDetailsExcel(
|
||||
startDateStr = "2026-04-01",
|
||||
endDateStr = "2026-04-30"
|
||||
)
|
||||
val outputStream = ByteArrayOutputStream()
|
||||
response.writeTo(outputStream)
|
||||
|
||||
assertTrue(outputStream.toByteArray().isNotEmpty())
|
||||
|
||||
XSSFWorkbook(ByteArrayInputStream(outputStream.toByteArray())).use { workbook ->
|
||||
assertEquals(0, workbook.numberOfSheets)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user