From ae0bf769f75288922235b7c262cf9a6062552e80 Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 21 Apr 2026 18:01:11 +0900 Subject: [PATCH] =?UTF-8?q?feat(original-series-calculate):=20=EC=98=A4?= =?UTF-8?q?=EB=A6=AC=EC=A7=80=EB=84=90=20=EC=8B=9C=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EC=A0=95=EC=82=B0=20=EB=82=B4=EC=97=AD=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=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 --- .../AdminOriginalSeriesCalculateController.kt | 65 +++++ ...nOriginalSeriesCalculateQueryRepository.kt | 114 ++++++++ .../AdminOriginalSeriesCalculateService.kt | 116 ++++++++ .../GetAdminOriginalSeriesOwnerResponse.kt | 9 + ...OriginalSeriesSettlementDetailQueryData.kt | 27 ++ ...nOriginalSeriesSettlementDetailResponse.kt | 18 ++ ...inOriginalSeriesCalculateControllerTest.kt | 114 ++++++++ ...ginalSeriesCalculateQueryRepositoryTest.kt | 264 ++++++++++++++++++ ...AdminOriginalSeriesCalculateServiceTest.kt | 167 +++++++++++ 9 files changed, 894 insertions(+) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/AdminOriginalSeriesCalculateController.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/AdminOriginalSeriesCalculateQueryRepository.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/AdminOriginalSeriesCalculateService.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/GetAdminOriginalSeriesOwnerResponse.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/GetAdminOriginalSeriesSettlementDetailQueryData.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/GetAdminOriginalSeriesSettlementDetailResponse.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/AdminOriginalSeriesCalculateControllerTest.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/AdminOriginalSeriesCalculateQueryRepositoryTest.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/AdminOriginalSeriesCalculateServiceTest.kt 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..4f5f826c --- /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("startDate") startDateStr: String, + @RequestParam("endDate") endDateStr: String, + @RequestParam memberId: Long, + pageable: Pageable + ) = ApiResponse.ok( + service.getSettlementDetails( + startDateStr = startDateStr, + endDateStr = endDateStr, + memberId = memberId, + offset = pageable.offset, + limit = pageable.pageSize.toLong() + ) + ) + + @GetMapping("/original-series/settlement-details/excel") + fun downloadSettlementDetailsExcel( + @RequestParam("startDate") startDateStr: String, + @RequestParam("endDate") 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..9dd09739 --- /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, + memberId: 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, memberId)) + .groupBy( + series.id, + series.title, + audioContent.id, + audioContent.title, + order.type, + audioContent.price + ) + .fetch() + .size + } + + fun getSettlementDetails( + startDate: LocalDateTime, + endDate: LocalDateTime, + memberId: 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, memberId)) + .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, + memberId: Long + ): BooleanExpression { + return series.isOriginal.isTrue + .and(series.isActive.isTrue) + .and(member.isActive.isTrue) + .and(member.id.eq(memberId)) + .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..9c9310af --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/AdminOriginalSeriesCalculateService.kt @@ -0,0 +1,116 @@ +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, + memberId: Long, + offset: Long, + limit: Long + ): GetAdminOriginalSeriesSettlementDetailListResponse { + val (startDate, endDate) = toDateRange(startDateStr, endDateStr) + val totalCount = repository.getSettlementDetailTotalCount(startDate, endDate, memberId) + val items = repository + .getSettlementDetails(startDate, endDate, memberId, 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.isEmpty()) { + writeHeaders(workbook.createSheet("오리지널 시리즈 정산")) + } else { + owners.forEach { owner -> + val sheet = workbook.createSheet(toSheetName(owner)) + writeHeaders(sheet) + + val totalCount = repository.getSettlementDetailTotalCount(startDate, endDate, owner.memberId) + val items = if (totalCount == 0) { + emptyList() + } else { + repository.getSettlementDetails(startDate, endDate, owner.memberId, 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.memberId}_${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..5d7f2d60 --- /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("memberId") val memberId: 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/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..e4b667eb --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/AdminOriginalSeriesCalculateControllerTest.kt @@ -0,0 +1,114 @@ +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(memberId = 1L, nickname = "owner-a")) + Mockito.`when`(service.getOriginalSeriesOwners()).thenReturn(owners) + + val response = controller.getOriginalSeriesOwners() + + assertEquals(true, response.success) + assertEquals(1, response.data!!.size) + 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", + memberId = 5L, + offset = 10L, + limit = 10L + ) + ).thenReturn(detailResponse) + + val response = controller.getSettlementDetails( + startDateStr = "2026-04-01", + endDateStr = "2026-04-30", + memberId = 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", + memberId = 5L, + offset = 10L, + limit = 10L + ) + } + + @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..e0aeb12c --- /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].memberId) + assertEquals(ownerB.id, result[1].memberId) + } + + @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..b8b5c0e0 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/originalSeries/AdminOriginalSeriesCalculateServiceTest.kt @@ -0,0 +1,167 @@ +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(memberId = 1L, nickname = "owner-a")) + Mockito.`when`(repository.getOriginalSeriesOwners()).thenReturn(owners) + + val result = service.getOriginalSeriesOwners() + + assertEquals(1, result.size) + assertEquals(1L, result[0].memberId) + 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", + memberId = 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(memberId = 1L, nickname = "owner-a"), + GetAdminOriginalSeriesOwnerResponse(memberId = 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("1_owner-a", workbook.getSheetAt(0).sheetName) + assertEquals("2_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 + ) + } +}