From 1fbad0f2bbfd4bca61ecfb475c28a1940eeffffe Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 3 Mar 2026 14:42:42 +0900 Subject: [PATCH] =?UTF-8?q?fix(channel-donation):=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=EC=9E=90=20=EC=B1=84=EB=84=90=ED=9B=84=EC=9B=90=20=EC=A0=95?= =?UTF-8?q?=EC=82=B0=20=EC=A1=B0=ED=9A=8C=EB=A5=BC=20=EB=82=A0=EC=A7=9C?= =?UTF-8?q?=EB=B3=84=EA=B3=BC=20=ED=81=AC=EB=A6=AC=EC=97=90=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=EB=B3=84=EB=A1=9C=20=EB=B6=84=EB=A6=AC=ED=95=98?= =?UTF-8?q?=EA=B3=A0=20=EC=97=91=EC=85=80=20=EB=8B=A4=EC=9A=B4=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...0303_관리자채널후원정산리뷰지적사항반영.md | 18 +++ ...자채널후원크리에이터별정산조회및엑셀API.md | 34 ++++++ ...AdminChannelDonationCalculateController.kt | 44 +++++++ ...ChannelDonationCalculateQueryRepository.kt | 79 ++++++++++++- .../AdminChannelDonationCalculateService.kt | 70 ++++++++++- ...nChannelDonationSettlementByCreatorItem.kt | 14 +++ ...nelDonationSettlementByCreatorQueryData.kt | 25 ++++ ...nnelDonationSettlementByCreatorResponse.kt | 7 ++ ...nChannelDonationCalculateControllerTest.kt | 101 +++++++++++++++- ...nelDonationCalculateQueryRepositoryTest.kt | 59 +++++++++- ...dminChannelDonationCalculateServiceTest.kt | 109 +++++++++++++++++- 11 files changed, 540 insertions(+), 20 deletions(-) create mode 100644 docs/20260303_관리자채널후원정산리뷰지적사항반영.md create mode 100644 docs/20260303_관리자채널후원크리에이터별정산조회및엑셀API.md create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/GetAdminChannelDonationSettlementByCreatorItem.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/GetAdminChannelDonationSettlementByCreatorQueryData.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/GetAdminChannelDonationSettlementByCreatorResponse.kt diff --git a/docs/20260303_관리자채널후원정산리뷰지적사항반영.md b/docs/20260303_관리자채널후원정산리뷰지적사항반영.md new file mode 100644 index 00000000..1e776b93 --- /dev/null +++ b/docs/20260303_관리자채널후원정산리뷰지적사항반영.md @@ -0,0 +1,18 @@ +# 관리자 채널후원 정산 리뷰 지적사항 반영 작업 계획 + +- [x] 하위 호환성 유지 이슈는 요구사항 재확인 결과, 기존 이름을 신규 목적 경로로 사용하기로 확정되어 작업 범위에서 제외한다. +- [x] 엑셀 다운로드 API 테스트에서 `Content-Disposition` 헤더를 실질적으로 검증하도록 보강한다. +- [x] 관련 테스트와 빌드를 실행해 회귀 여부를 확인한다. + +## 검증 기록 + +### 1차 반영 +- 무엇을: 엑셀 다운로드 컨트롤러 테스트에서 `Content-Disposition` 헤더를 `getFirst(HttpHeaders.CONTENT_DISPOSITION)`로 조회하고, `attachment; filename*=` 포함 여부를 검증하도록 수정했다. +- 왜: 기존 `response.headers.contentDisposition` null 체크만으로는 헤더 누락/형식 회귀를 충분히 잡지 못해 테스트 신뢰도를 높이기 위해서다. +- 어떻게: + - 코드 변경: `src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/AdminChannelDonationCalculateControllerTest.kt` + - 범위 조정: 하위 호환성 유지 이슈는 요구사항 재확인 결과 작업 제외로 확정 + - 실행 결과: + - `lsp_diagnostics (AdminChannelDonationCalculateControllerTest.kt)` → Kotlin LSP 미설정으로 진단 불가 + - `./gradlew test --tests "*AdminChannelDonationCalculateControllerTest"` → 성공 + - `./gradlew build` → 성공 diff --git a/docs/20260303_관리자채널후원크리에이터별정산조회및엑셀API.md b/docs/20260303_관리자채널후원크리에이터별정산조회및엑셀API.md new file mode 100644 index 00000000..c31be18d --- /dev/null +++ b/docs/20260303_관리자채널후원크리에이터별정산조회및엑셀API.md @@ -0,0 +1,34 @@ +# 관리자 채널후원 크리에이터별 정산 조회 및 엑셀 API 작업 계획 + +- [x] 기존 관리자 채널후원 정산 API의 날짜별 조회 경로를 식별하고 URL 변경 범위를 확정한다. +- [x] 관리자 채널후원 정산 날짜별 조회 API URL을 목적에 맞게 변경한다. +- [x] 관리자 크리에이터별 채널후원 정산 조회 API(`GET /admin/calculate/channel-donation-by-creator`)를 구현한다. +- [x] 관리자 크리에이터별 채널후원 정산 엑셀 다운로드 API(`GET /admin/calculate/channel-donation-by-creator/excel`)를 구현한다. +- [x] 크리에이터별 집계/카운트/합계 Query를 추가하고, 정산 계산 비율은 기존 채널후원 정산과 동일하게 적용한다. +- [x] 관련 테스트를 수정/추가하고 `./gradlew test`, `./gradlew build`로 검증한다. + +## 검증 기록 + +### 1차 구현 +- 무엇을: 관리자 채널후원 정산 API를 날짜별/크리에이터별로 분리하고, 크리에이터별 정산 엑셀 다운로드 API를 추가했다. +- 왜: 기존 `/admin/calculate/channel-donation-by-creator`가 날짜별 조회 성격이어서 URL 의미를 분리하고, 요청한 크리에이터별 목록/엑셀 기능을 제공하기 위해서다. +- 어떻게: + - 컨트롤러에서 기존 날짜별 조회 경로를 `GET /admin/calculate/channel-donation-by-date`로 변경했다. + - 신규 크리에이터별 조회 `GET /admin/calculate/channel-donation-by-creator`와 엑셀 다운로드 `GET /admin/calculate/channel-donation-by-creator/excel`를 추가했다. + - QueryRepository에 날짜별/크리에이터별 집계 메서드를 분리하고, 크리에이터별 총건수(distinct creator) 및 엑셀용 전체 조회를 추가했다. + - 서비스에서 크리에이터별 조회 응답 DTO와 엑셀(XSSFWorkbook) 생성 로직을 구현했다. + - 정산 비율/공식은 기존 `ChannelDonationSettlementCalculator`를 그대로 사용해 동일 정책을 유지했다. + - 테스트를 수정/추가해 날짜별 라우팅, 크리에이터별 조회, 엑셀 다운로드, Query 집계를 검증했다. + - 실행 결과: + - `lsp_diagnostics` (수정된 `.kt` 파일들) → Kotlin LSP 미설정으로 진단 불가 + - `./gradlew test --tests "*channelDonation*"` → 성공 + - `./gradlew build` → 성공 + +### 2차 수정 +- 무엇을: 크리에이터별 정산 엑셀 다운로드 파일의 시트명과 헤더를 한글로 변경했다. +- 왜: 관리자 화면에서 다운로드한 엑셀의 컬럼 의미를 즉시 식별할 수 있도록 가독성을 높이기 위해서다. +- 어떻게: + - 시트명 `channel-donation-by-creator`를 `크리에이터별 채널후원 정산`으로 변경했다. + - 헤더를 `크리에이터`, `건수`, `총 받은 캔 수`, `원화`, `수수료`, `정산금액`, `원천세`, `입금액`으로 변경했다. + - 실행 결과: + - `./gradlew test --tests "*channelDonation*"` → 성공 diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/AdminChannelDonationCalculateController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/AdminChannelDonationCalculateController.kt index df1b74a4..debdf252 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/AdminChannelDonationCalculateController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/AdminChannelDonationCalculateController.kt @@ -1,12 +1,18 @@ package kr.co.vividnext.sodalive.admin.calculate.channelDonation import kr.co.vividnext.sodalive.common.ApiResponse +import org.springframework.core.io.InputStreamResource 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 java.net.URLEncoder +import java.nio.charset.StandardCharsets @RestController @PreAuthorize("hasRole('ADMIN')") @@ -14,6 +20,20 @@ import org.springframework.web.bind.annotation.RestController class AdminChannelDonationCalculateController( private val service: AdminChannelDonationCalculateService ) { + @GetMapping("/channel-donation-by-date") + fun getChannelDonationByDate( + @RequestParam startDateStr: String, + @RequestParam endDateStr: String, + pageable: Pageable + ) = ApiResponse.ok( + service.getChannelDonationByDate( + startDateStr = startDateStr, + endDateStr = endDateStr, + offset = pageable.offset, + limit = pageable.pageSize.toLong() + ) + ) + @GetMapping("/channel-donation-by-creator") fun getChannelDonationByCreator( @RequestParam startDateStr: String, @@ -27,4 +47,28 @@ class AdminChannelDonationCalculateController( limit = pageable.pageSize.toLong() ) ) + + @GetMapping("/channel-donation-by-creator/excel") + fun downloadChannelDonationByCreatorExcel( + @RequestParam startDateStr: String, + @RequestParam endDateStr: String + ): ResponseEntity { + val encodedFileName = URLEncoder.encode( + "channel-donation-by-creator.xlsx", + StandardCharsets.UTF_8.toString() + ).replace("+", "%20") + val headers = HttpHeaders().apply { + add(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''$encodedFileName") + } + + val response = service.downloadChannelDonationByCreatorExcel( + startDateStr = startDateStr, + endDateStr = endDateStr + ) + + return ResponseEntity.ok() + .headers(headers) + .contentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")) + .body(InputStreamResource(response)) + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/AdminChannelDonationCalculateQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/AdminChannelDonationCalculateQueryRepository.kt index b6859de7..59a8af2b 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/AdminChannelDonationCalculateQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/AdminChannelDonationCalculateQueryRepository.kt @@ -16,9 +16,23 @@ import java.time.LocalDateTime class AdminChannelDonationCalculateQueryRepository( private val queryFactory: JPAQueryFactory ) { + fun getChannelDonationByDateTotal( + startDate: LocalDateTime, + endDate: LocalDateTime + ): GetAdminChannelDonationSettlementTotalQueryData { + return getChannelDonationSettlementTotal(startDate, endDate) + } + fun getChannelDonationByCreatorTotal( startDate: LocalDateTime, endDate: LocalDateTime + ): GetAdminChannelDonationSettlementTotalQueryData { + return getChannelDonationSettlementTotal(startDate, endDate) + } + + private fun getChannelDonationSettlementTotal( + startDate: LocalDateTime, + endDate: LocalDateTime ): GetAdminChannelDonationSettlementTotalQueryData { return queryFactory .select( @@ -39,7 +53,7 @@ class AdminChannelDonationCalculateQueryRepository( ) } - fun getChannelDonationByCreatorTotalCount(startDate: LocalDateTime, endDate: LocalDateTime): Int { + fun getChannelDonationByDateTotalCount(startDate: LocalDateTime, endDate: LocalDateTime): Int { val formattedDate = getFormattedDate(useCan.createdAt) val distinctGroupKey = Expressions.stringTemplate( "CONCAT({0}, '-', {1})", @@ -59,7 +73,20 @@ class AdminChannelDonationCalculateQueryRepository( ?: 0 } - fun getChannelDonationByCreator( + fun getChannelDonationByCreatorTotalCount(startDate: LocalDateTime, endDate: LocalDateTime): Int { + return queryFactory + .select(member.id.countDistinct()) + .from(useCanCalculate) + .innerJoin(useCanCalculate.useCan, useCan) + .innerJoin(member) + .on(member.id.eq(useCanCalculate.recipientCreatorId)) + .where(baseWhereCondition(startDate, endDate)) + .fetchOne() + ?.toInt() + ?: 0 + } + + fun getChannelDonationByDate( startDate: LocalDateTime, endDate: LocalDateTime, offset: Long, @@ -88,6 +115,54 @@ class AdminChannelDonationCalculateQueryRepository( .fetch() } + fun getChannelDonationByCreator( + startDate: LocalDateTime, + endDate: LocalDateTime, + offset: Long, + limit: Long + ): List { + return queryFactory + .select( + QGetAdminChannelDonationSettlementByCreatorQueryData( + member.nickname, + useCan.id.countDistinct(), + useCanCalculate.can.sum() + ) + ) + .from(useCanCalculate) + .innerJoin(useCanCalculate.useCan, useCan) + .innerJoin(member) + .on(member.id.eq(useCanCalculate.recipientCreatorId)) + .where(baseWhereCondition(startDate, endDate)) + .groupBy(member.id) + .orderBy(member.id.desc()) + .offset(offset) + .limit(limit) + .fetch() + } + + fun getChannelDonationByCreatorForExcel( + startDate: LocalDateTime, + endDate: LocalDateTime + ): List { + return queryFactory + .select( + QGetAdminChannelDonationSettlementByCreatorQueryData( + member.nickname, + useCan.id.countDistinct(), + useCanCalculate.can.sum() + ) + ) + .from(useCanCalculate) + .innerJoin(useCanCalculate.useCan, useCan) + .innerJoin(member) + .on(member.id.eq(useCanCalculate.recipientCreatorId)) + .where(baseWhereCondition(startDate, endDate)) + .groupBy(member.id) + .orderBy(member.id.desc()) + .fetch() + } + private fun baseWhereCondition( startDate: LocalDateTime, endDate: LocalDateTime diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/AdminChannelDonationCalculateService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/AdminChannelDonationCalculateService.kt index 548106d2..22a9d54a 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/AdminChannelDonationCalculateService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/AdminChannelDonationCalculateService.kt @@ -1,15 +1,18 @@ package kr.co.vividnext.sodalive.admin.calculate.channelDonation import kr.co.vividnext.sodalive.extensions.convertLocalDateTime +import org.apache.poi.xssf.usermodel.XSSFWorkbook import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream @Service class AdminChannelDonationCalculateService( private val repository: AdminChannelDonationCalculateQueryRepository ) { @Transactional(readOnly = true) - fun getChannelDonationByCreator( + fun getChannelDonationByDate( startDateStr: String, endDateStr: String, offset: Long, @@ -18,12 +21,75 @@ class AdminChannelDonationCalculateService( val startDate = startDateStr.convertLocalDateTime() val endDate = endDateStr.convertLocalDateTime(hour = 23, minute = 59, second = 59) + val total = repository.getChannelDonationByDateTotal(startDate, endDate).toResponseTotal() + val totalCount = repository.getChannelDonationByDateTotalCount(startDate, endDate) + val items = repository + .getChannelDonationByDate(startDate, endDate, offset, limit) + .map { it.toResponseItem() } + + return GetAdminChannelDonationSettlementResponse(totalCount, total, items) + } + + @Transactional(readOnly = true) + fun getChannelDonationByCreator( + startDateStr: String, + endDateStr: String, + offset: Long, + limit: Long + ): GetAdminChannelDonationSettlementByCreatorResponse { + val startDate = startDateStr.convertLocalDateTime() + val endDate = endDateStr.convertLocalDateTime(hour = 23, minute = 59, second = 59) + val total = repository.getChannelDonationByCreatorTotal(startDate, endDate).toResponseTotal() val totalCount = repository.getChannelDonationByCreatorTotalCount(startDate, endDate) val items = repository .getChannelDonationByCreator(startDate, endDate, offset, limit) .map { it.toResponseItem() } - return GetAdminChannelDonationSettlementResponse(totalCount, total, items) + return GetAdminChannelDonationSettlementByCreatorResponse(totalCount, total, items) + } + + @Transactional(readOnly = true) + fun downloadChannelDonationByCreatorExcel(startDateStr: String, endDateStr: String): ByteArrayInputStream { + val startDate = startDateStr.convertLocalDateTime() + val endDate = endDateStr.convertLocalDateTime(hour = 23, minute = 59, second = 59) + val items = repository + .getChannelDonationByCreatorForExcel(startDate, endDate) + .map { it.toResponseItem() } + val byteArrayOutputStream = ByteArrayOutputStream() + + XSSFWorkbook().use { workbook -> + val sheet = workbook.createSheet("크리에이터별 채널후원 정산") + val header = listOf( + "크리에이터", + "건수", + "총 받은 캔 수", + "원화", + "수수료", + "정산금액", + "원천세", + "입금액" + ) + val headerRow = sheet.createRow(0) + header.forEachIndexed { index, value -> + headerRow.createCell(index).setCellValue(value) + } + + items.forEachIndexed { index, item -> + val row = sheet.createRow(index + 1) + row.createCell(0).setCellValue(item.creator) + row.createCell(1).setCellValue(item.count.toDouble()) + row.createCell(2).setCellValue(item.totalCan.toDouble()) + row.createCell(3).setCellValue(item.krw.toDouble()) + row.createCell(4).setCellValue(item.fee.toDouble()) + row.createCell(5).setCellValue(item.settlementAmount.toDouble()) + row.createCell(6).setCellValue(item.withholdingTax.toDouble()) + row.createCell(7).setCellValue(item.depositAmount.toDouble()) + } + + workbook.write(byteArrayOutputStream) + } + + return ByteArrayInputStream(byteArrayOutputStream.toByteArray()) } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/GetAdminChannelDonationSettlementByCreatorItem.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/GetAdminChannelDonationSettlementByCreatorItem.kt new file mode 100644 index 00000000..f706a28c --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/GetAdminChannelDonationSettlementByCreatorItem.kt @@ -0,0 +1,14 @@ +package kr.co.vividnext.sodalive.admin.calculate.channelDonation + +import com.fasterxml.jackson.annotation.JsonProperty + +data class GetAdminChannelDonationSettlementByCreatorItem( + @JsonProperty("creator") val creator: String, + @JsonProperty("count") val count: Int, + @JsonProperty("totalCan") val totalCan: Int, + @JsonProperty("krw") val krw: Int, + @JsonProperty("fee") val fee: Int, + @JsonProperty("settlementAmount") val settlementAmount: Int, + @JsonProperty("withholdingTax") val withholdingTax: Int, + @JsonProperty("depositAmount") val depositAmount: Int +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/GetAdminChannelDonationSettlementByCreatorQueryData.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/GetAdminChannelDonationSettlementByCreatorQueryData.kt new file mode 100644 index 00000000..cc951785 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/GetAdminChannelDonationSettlementByCreatorQueryData.kt @@ -0,0 +1,25 @@ +package kr.co.vividnext.sodalive.admin.calculate.channelDonation + +import com.querydsl.core.annotations.QueryProjection +import kr.co.vividnext.sodalive.calculate.channelDonation.ChannelDonationSettlementCalculator + +data class GetAdminChannelDonationSettlementByCreatorQueryData @QueryProjection constructor( + val creator: String, + val count: Long, + val totalCan: Int? +) { + fun toResponseItem(): GetAdminChannelDonationSettlementByCreatorItem { + val settlement = ChannelDonationSettlementCalculator.calculate(totalCan ?: 0) + + return GetAdminChannelDonationSettlementByCreatorItem( + creator = creator, + count = count.toInt(), + totalCan = totalCan ?: 0, + krw = settlement.krw, + fee = settlement.fee, + settlementAmount = settlement.settlementAmount, + withholdingTax = settlement.withholdingTax, + depositAmount = settlement.depositAmount + ) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/GetAdminChannelDonationSettlementByCreatorResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/GetAdminChannelDonationSettlementByCreatorResponse.kt new file mode 100644 index 00000000..f8a5d472 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/GetAdminChannelDonationSettlementByCreatorResponse.kt @@ -0,0 +1,7 @@ +package kr.co.vividnext.sodalive.admin.calculate.channelDonation + +data class GetAdminChannelDonationSettlementByCreatorResponse( + val totalCount: Int, + val total: GetAdminChannelDonationSettlementTotal, + val items: List +) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/AdminChannelDonationCalculateControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/AdminChannelDonationCalculateControllerTest.kt index 7261b448..38e76c62 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/AdminChannelDonationCalculateControllerTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/AdminChannelDonationCalculateControllerTest.kt @@ -1,11 +1,15 @@ package kr.co.vividnext.sodalive.admin.calculate.channelDonation 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.core.io.InputStreamResource import org.springframework.data.domain.PageRequest +import org.springframework.http.HttpHeaders +import java.io.ByteArrayInputStream class AdminChannelDonationCalculateControllerTest { private lateinit var service: AdminChannelDonationCalculateService @@ -18,8 +22,8 @@ class AdminChannelDonationCalculateControllerTest { } @Test - @DisplayName("관리자 컨트롤러는 날짜/페이지 파라미터를 서비스로 전달한다") - fun shouldForwardDateRangeAndPageableToService() { + @DisplayName("관리자 컨트롤러는 날짜별 조회 파라미터를 서비스로 전달한다") + fun shouldForwardDateRangeAndPageableToDateService() { val response = GetAdminChannelDonationSettlementResponse( totalCount = 1, total = GetAdminChannelDonationSettlementTotal( @@ -47,7 +51,7 @@ class AdminChannelDonationCalculateControllerTest { ) Mockito.`when`( - service.getChannelDonationByCreator( + service.getChannelDonationByDate( startDateStr = "2026-02-20", endDateStr = "2026-02-21", offset = 15L, @@ -55,7 +59,7 @@ class AdminChannelDonationCalculateControllerTest { ) ).thenReturn(response) - val apiResponse = controller.getChannelDonationByCreator( + val apiResponse = controller.getChannelDonationByDate( startDateStr = "2026-02-20", endDateStr = "2026-02-21", pageable = PageRequest.of(1, 15) @@ -68,11 +72,98 @@ class AdminChannelDonationCalculateControllerTest { assertEquals(2, apiResponse.data!!.items[0].count) assertEquals(20, apiResponse.data!!.items[0].totalCan) - Mockito.verify(service).getChannelDonationByCreator( + Mockito.verify(service).getChannelDonationByDate( startDateStr = "2026-02-20", endDateStr = "2026-02-21", offset = 15L, limit = 15L ) } + + @Test + @DisplayName("관리자 컨트롤러는 크리에이터별 조회 파라미터를 서비스로 전달한다") + fun shouldForwardDateRangeAndPageableToCreatorService() { + val response = GetAdminChannelDonationSettlementByCreatorResponse( + totalCount = 1, + total = GetAdminChannelDonationSettlementTotal( + count = 5, + totalCan = 35, + krw = 3500, + fee = 231, + settlementAmount = 2770, + withholdingTax = 91, + depositAmount = 2679 + ), + items = listOf( + GetAdminChannelDonationSettlementByCreatorItem( + creator = "creator-a", + count = 2, + totalCan = 20, + krw = 2000, + fee = 132, + settlementAmount = 1588, + withholdingTax = 52, + depositAmount = 1536 + ) + ) + ) + + Mockito.`when`( + service.getChannelDonationByCreator( + startDateStr = "2026-02-20", + endDateStr = "2026-02-21", + offset = 0L, + limit = 20L + ) + ).thenReturn(response) + + val apiResponse = controller.getChannelDonationByCreator( + startDateStr = "2026-02-20", + endDateStr = "2026-02-21", + pageable = PageRequest.of(0, 20) + ) + + assertEquals(true, apiResponse.success) + assertEquals(1, apiResponse.data!!.totalCount) + assertEquals("creator-a", apiResponse.data!!.items[0].creator) + + Mockito.verify(service).getChannelDonationByCreator( + startDateStr = "2026-02-20", + endDateStr = "2026-02-21", + offset = 0L, + limit = 20L + ) + } + + @Test + @DisplayName("관리자 컨트롤러는 크리에이터별 정산 엑셀을 다운로드한다") + fun shouldDownloadCreatorSettlementExcel() { + Mockito.`when`( + service.downloadChannelDonationByCreatorExcel( + startDateStr = "2026-02-01", + endDateStr = "2026-02-29" + ) + ).thenReturn(ByteArrayInputStream(byteArrayOf(1, 2, 3))) + + val response = controller.downloadChannelDonationByCreatorExcel( + startDateStr = "2026-02-01", + endDateStr = "2026-02-29" + ) + + 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 InputStreamResource) + + Mockito.verify(service).downloadChannelDonationByCreatorExcel( + startDateStr = "2026-02-01", + endDateStr = "2026-02-29" + ) + } } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/AdminChannelDonationCalculateQueryRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/AdminChannelDonationCalculateQueryRepositoryTest.kt index 445603c0..97944d53 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/AdminChannelDonationCalculateQueryRepositoryTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/AdminChannelDonationCalculateQueryRepositoryTest.kt @@ -40,8 +40,8 @@ class AdminChannelDonationCalculateQueryRepositoryTest @Autowired constructor( } @Test - @DisplayName("동일 후원의 분할 정산 레코드는 건수를 중복 집계하지 않는다") - fun shouldCountDistinctUseCanWhenDonationIsSplitAcrossCalculations() { + @DisplayName("날짜별 조회는 동일 후원의 분할 정산 레코드를 건수 중복 집계하지 않는다") + fun shouldCountDistinctUseCanWhenDonationIsSplitAcrossCalculationsByDate() { val creator = saveMember(nickname = "creator-admin", role = MemberRole.CREATOR) val sender = saveMember(nickname = "sender-admin", role = MemberRole.USER) val useCan = saveUseCan(member = sender, can = 50, rewardCan = 0) @@ -65,9 +65,9 @@ class AdminChannelDonationCalculateQueryRepositoryTest @Autowired constructor( val startDate = LocalDateTime.of(2026, 2, 20, 0, 0, 0) val endDate = LocalDateTime.of(2026, 2, 20, 23, 59, 59) - val total = repository.getChannelDonationByCreatorTotal(startDate, endDate) - val totalCount = repository.getChannelDonationByCreatorTotalCount(startDate, endDate) - val items = repository.getChannelDonationByCreator(startDate, endDate, offset = 0, limit = 20) + val total = repository.getChannelDonationByDateTotal(startDate, endDate) + val totalCount = repository.getChannelDonationByDateTotalCount(startDate, endDate) + val items = repository.getChannelDonationByDate(startDate, endDate, offset = 0, limit = 20) assertEquals(1L, total.count) assertEquals(50, total.totalCan) @@ -79,6 +79,55 @@ class AdminChannelDonationCalculateQueryRepositoryTest @Autowired constructor( assertEquals(50, items[0].totalCan) } + @Test + @DisplayName("크리에이터별 조회는 크리에이터 기준으로 집계한다") + fun shouldAggregateByCreator() { + val creatorA = saveMember(nickname = "creator-a", role = MemberRole.CREATOR) + val creatorB = saveMember(nickname = "creator-b", role = MemberRole.CREATOR) + val sender = saveMember(nickname = "sender-admin", role = MemberRole.USER) + + val useCanA = saveUseCan(member = sender, can = 70, rewardCan = 0) + val useCanB = saveUseCan(member = sender, can = 30, rewardCan = 0) + + saveUseCanCalculate( + useCan = useCanA, + recipientCreatorId = creatorA.id!!, + can = 70, + paymentGateway = PaymentGateway.PG + ) + saveUseCanCalculate( + useCan = useCanB, + recipientCreatorId = creatorB.id!!, + can = 30, + paymentGateway = PaymentGateway.PG + ) + + updateUseCanCreatedAt(useCanA.id!!, LocalDateTime.of(2026, 2, 20, 10, 0, 0)) + updateUseCanCreatedAt(useCanB.id!!, LocalDateTime.of(2026, 2, 20, 11, 0, 0)) + entityManager.flush() + entityManager.clear() + + val startDate = LocalDateTime.of(2026, 2, 20, 0, 0, 0) + val endDate = LocalDateTime.of(2026, 2, 20, 23, 59, 59) + + val total = repository.getChannelDonationByCreatorTotal(startDate, endDate) + val totalCount = repository.getChannelDonationByCreatorTotalCount(startDate, endDate) + val items = repository.getChannelDonationByCreator(startDate, endDate, offset = 0, limit = 20) + val excelItems = repository.getChannelDonationByCreatorForExcel(startDate, endDate) + + assertEquals(2L, total.count) + assertEquals(100, total.totalCan) + assertEquals(2, totalCount) + assertEquals(2, items.size) + assertEquals(2, excelItems.size) + assertEquals("creator-b", items[0].creator) + assertEquals(1L, items[0].count) + assertEquals(30, items[0].totalCan) + assertEquals("creator-a", items[1].creator) + assertEquals(1L, items[1].count) + assertEquals(70, items[1].totalCan) + } + private fun saveMember(nickname: String, role: MemberRole): Member { return memberRepository.saveAndFlush( Member( diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/AdminChannelDonationCalculateServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/AdminChannelDonationCalculateServiceTest.kt index 763f1f67..28a8b221 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/AdminChannelDonationCalculateServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/channelDonation/AdminChannelDonationCalculateServiceTest.kt @@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.admin.calculate.channelDonation import kr.co.vividnext.sodalive.extensions.convertLocalDateTime 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 @@ -18,8 +19,8 @@ class AdminChannelDonationCalculateServiceTest { } @Test - @DisplayName("관리자 정산 조회는 날짜 범위를 변환하고 크리에이터 그룹 응답을 반환한다") - fun shouldConvertDateRangeAndReturnCreatorGroupedItems() { + @DisplayName("관리자 날짜별 정산 조회는 날짜 범위를 변환하고 그룹 응답을 반환한다") + fun shouldConvertDateRangeAndReturnDateGroupedItems() { val queryData = GetAdminChannelDonationSettlementQueryData( date = "2026-02-26", creator = "creator-a", @@ -31,6 +32,75 @@ class AdminChannelDonationCalculateServiceTest { totalCan = 250 ) + Mockito.`when`( + repository.getChannelDonationByDateTotal( + "2026-02-20".convertLocalDateTime(), + "2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59) + ) + ).thenReturn(totalQueryData) + Mockito.`when`( + repository.getChannelDonationByDateTotalCount( + "2026-02-20".convertLocalDateTime(), + "2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59) + ) + ).thenReturn(1) + Mockito.`when`( + repository.getChannelDonationByDate( + "2026-02-20".convertLocalDateTime(), + "2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59), + 0L, + 20L + ) + ).thenReturn(listOf(queryData)) + + val result = service.getChannelDonationByDate( + startDateStr = "2026-02-20", + endDateStr = "2026-02-21", + offset = 0, + limit = 20 + ) + + assertEquals(1, result.totalCount) + assertEquals(8, result.total.count) + assertEquals(250, result.total.totalCan) + assertEquals(25_000, result.total.krw) + assertEquals(1, result.items.size) + assertEquals("2026-02-26", result.items[0].date) + assertEquals("creator-a", result.items[0].creator) + assertEquals(3, result.items[0].count) + assertEquals(100, result.items[0].totalCan) + assertEquals(10_000, result.items[0].krw) + assertEquals(660, result.items[0].fee) + + Mockito.verify(repository).getChannelDonationByDateTotal( + "2026-02-20".convertLocalDateTime(), + "2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59) + ) + Mockito.verify(repository).getChannelDonationByDateTotalCount( + "2026-02-20".convertLocalDateTime(), + "2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59) + ) + Mockito.verify(repository).getChannelDonationByDate( + "2026-02-20".convertLocalDateTime(), + "2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59), + 0L, + 20L + ) + } + + @Test + @DisplayName("관리자 크리에이터별 정산 조회는 날짜 범위를 변환하고 크리에이터 그룹 응답을 반환한다") + fun shouldConvertDateRangeAndReturnCreatorGroupedItems() { + val queryData = GetAdminChannelDonationSettlementByCreatorQueryData( + creator = "creator-a", + count = 3L, + totalCan = 100 + ) + val totalQueryData = GetAdminChannelDonationSettlementTotalQueryData( + count = 8L, + totalCan = 250 + ) + Mockito.`when`( repository.getChannelDonationByCreatorTotal( "2026-02-20".convertLocalDateTime(), @@ -62,14 +132,10 @@ class AdminChannelDonationCalculateServiceTest { assertEquals(1, result.totalCount) assertEquals(8, result.total.count) assertEquals(250, result.total.totalCan) - assertEquals(25_000, result.total.krw) assertEquals(1, result.items.size) - assertEquals("2026-02-26", result.items[0].date) assertEquals("creator-a", result.items[0].creator) assertEquals(3, result.items[0].count) assertEquals(100, result.items[0].totalCan) - assertEquals(10_000, result.items[0].krw) - assertEquals(660, result.items[0].fee) Mockito.verify(repository).getChannelDonationByCreatorTotal( "2026-02-20".convertLocalDateTime(), @@ -86,4 +152,35 @@ class AdminChannelDonationCalculateServiceTest { 20L ) } + + @Test + @DisplayName("관리자 크리에이터별 정산 엑셀 다운로드는 xlsx 바이트를 생성한다") + fun shouldGenerateCreatorSettlementExcelBytes() { + Mockito.`when`( + repository.getChannelDonationByCreatorForExcel( + "2026-02-20".convertLocalDateTime(), + "2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59) + ) + ).thenReturn( + listOf( + GetAdminChannelDonationSettlementByCreatorQueryData( + creator = "creator-a", + count = 3L, + totalCan = 100 + ) + ) + ) + + val response = service.downloadChannelDonationByCreatorExcel( + startDateStr = "2026-02-20", + endDateStr = "2026-02-21" + ) + + assertTrue(response.readAllBytes().isNotEmpty()) + + Mockito.verify(repository).getChannelDonationByCreatorForExcel( + "2026-02-20".convertLocalDateTime(), + "2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59) + ) + } }