diff --git a/docs/20260421_라이브방무료여부응답추가.md b/docs/20260421_라이브방무료여부응답추가.md new file mode 100644 index 00000000..6800b8b6 --- /dev/null +++ b/docs/20260421_라이브방무료여부응답추가.md @@ -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는 수행하지 못했다. diff --git a/docs/20260421_오리지널시리즈정산내역.md b/docs/20260421_오리지널시리즈정산내역.md new file mode 100644 index 00000000..e0d58056 --- /dev/null +++ b/docs/20260421_오리지널시리즈정산내역.md @@ -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> 형태다. + +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`) → 성공 diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/AdminOriginalSeriesCalculateController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/AdminOriginalSeriesCalculateController.kt new file mode 100644 index 00000000..3939430b --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/AdminOriginalSeriesCalculateController.kt @@ -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 = createExcelResponse( + fileName = "original-series-settlement-details.xlsx", + response = service.downloadSettlementDetailsExcel(startDateStr, endDateStr) + ) + + private fun createExcelResponse( + fileName: String, + response: StreamingResponseBody + ): ResponseEntity { + 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) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/AdminOriginalSeriesCalculateQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/AdminOriginalSeriesCalculateQueryRepository.kt new file mode 100644 index 00000000..7fd8b630 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/AdminOriginalSeriesCalculateQueryRepository.kt @@ -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 { + 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 { + 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)) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/AdminOriginalSeriesCalculateService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/AdminOriginalSeriesCalculateService.kt new file mode 100644 index 00000000..048cec36 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/AdminOriginalSeriesCalculateService.kt @@ -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 { + 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) { + 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 { + 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( + "시리즈 제목", + "콘텐츠 제목", + "가격(캔)", + "구분", + "판매 수", + "합계(캔)", + "합계(포인트)" + ) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/GetAdminOriginalSeriesOwnerResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/GetAdminOriginalSeriesOwnerResponse.kt new file mode 100644 index 00000000..ef90db94 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/GetAdminOriginalSeriesOwnerResponse.kt @@ -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 +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/GetAdminOriginalSeriesSettlementDetailQueryData.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/GetAdminOriginalSeriesSettlementDetailQueryData.kt new file mode 100644 index 00000000..1fd094d4 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/GetAdminOriginalSeriesSettlementDetailQueryData.kt @@ -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 + ) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/GetAdminOriginalSeriesSettlementDetailResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/GetAdminOriginalSeriesSettlementDetailResponse.kt new file mode 100644 index 00000000..2871d2ee --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/GetAdminOriginalSeriesSettlementDetailResponse.kt @@ -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 +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomService.kt index 92eac271..c724cbaa 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomService.kt @@ -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, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/info/GetRoomInfoResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/info/GetRoomInfoResponse.kt index b968ff49..2225346f 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/info/GetRoomInfoResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/info/GetRoomInfoResponse.kt @@ -23,6 +23,7 @@ data class GetRoomInfoResponse( val donationRankingTop3UserIds: List, val menuPan: String, val creatorLanguageCode: String?, + val isFreeRoom: Boolean, val isPrivateRoom: Boolean = false, val password: String? = null, val isActiveRoulette: Boolean = false, diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/AdminOriginalSeriesCalculateControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/AdminOriginalSeriesCalculateControllerTest.kt new file mode 100644 index 00000000..58eadce1 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/AdminOriginalSeriesCalculateControllerTest.kt @@ -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" + ) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/AdminOriginalSeriesCalculateQueryRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/AdminOriginalSeriesCalculateQueryRepositoryTest.kt new file mode 100644 index 00000000..8cb015dd --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/AdminOriginalSeriesCalculateQueryRepositoryTest.kt @@ -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) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/AdminOriginalSeriesCalculateServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/AdminOriginalSeriesCalculateServiceTest.kt new file mode 100644 index 00000000..15cea8db --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/AdminOriginalSeriesCalculateServiceTest.kt @@ -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) + } + } +}