feat(original-series-calculate): 오리지널 시리즈 정산 내역 조회를 추가한다
This commit is contained in:
@@ -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<StreamingResponseBody> = createExcelResponse(
|
||||||
|
fileName = "original-series-settlement-details.xlsx",
|
||||||
|
response = service.downloadSettlementDetailsExcel(startDateStr, endDateStr)
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun createExcelResponse(
|
||||||
|
fileName: String,
|
||||||
|
response: StreamingResponseBody
|
||||||
|
): ResponseEntity<StreamingResponseBody> {
|
||||||
|
val encodedFileName = URLEncoder.encode(fileName, StandardCharsets.UTF_8.toString()).replace("+", "%20")
|
||||||
|
val headers = HttpHeaders().apply {
|
||||||
|
add(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''$encodedFileName")
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResponseEntity.ok()
|
||||||
|
.headers(headers)
|
||||||
|
.contentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))
|
||||||
|
.body(response)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.calculate.originalSeries
|
||||||
|
|
||||||
|
import com.querydsl.core.types.dsl.BooleanExpression
|
||||||
|
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||||
|
import kr.co.vividnext.sodalive.content.QAudioContent.audioContent
|
||||||
|
import kr.co.vividnext.sodalive.content.order.QOrder.order
|
||||||
|
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeries.series
|
||||||
|
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeriesContent.seriesContent
|
||||||
|
import kr.co.vividnext.sodalive.member.QMember.member
|
||||||
|
import org.springframework.stereotype.Repository
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
class AdminOriginalSeriesCalculateQueryRepository(
|
||||||
|
private val queryFactory: JPAQueryFactory
|
||||||
|
) {
|
||||||
|
fun getOriginalSeriesOwners(): List<GetAdminOriginalSeriesOwnerResponse> {
|
||||||
|
return queryFactory
|
||||||
|
.select(
|
||||||
|
QGetAdminOriginalSeriesOwnerResponse(
|
||||||
|
member.id,
|
||||||
|
member.nickname
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.from(series)
|
||||||
|
.innerJoin(series.member, member)
|
||||||
|
.where(
|
||||||
|
series.isOriginal.isTrue
|
||||||
|
.and(series.isActive.isTrue)
|
||||||
|
.and(member.isActive.isTrue)
|
||||||
|
)
|
||||||
|
.groupBy(member.id, member.nickname)
|
||||||
|
.orderBy(member.id.asc())
|
||||||
|
.fetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getSettlementDetailTotalCount(
|
||||||
|
startDate: LocalDateTime,
|
||||||
|
endDate: LocalDateTime,
|
||||||
|
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<GetAdminOriginalSeriesSettlementDetailQueryData> {
|
||||||
|
return queryFactory
|
||||||
|
.select(
|
||||||
|
QGetAdminOriginalSeriesSettlementDetailQueryData(
|
||||||
|
series.title,
|
||||||
|
audioContent.title,
|
||||||
|
audioContent.price,
|
||||||
|
order.type,
|
||||||
|
order.id.count(),
|
||||||
|
order.can.sum().coalesce(0),
|
||||||
|
order.point.sum().coalesce(0)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.from(order)
|
||||||
|
.innerJoin(order.audioContent, audioContent)
|
||||||
|
.innerJoin(seriesContent).on(seriesContent.content.id.eq(audioContent.id))
|
||||||
|
.innerJoin(seriesContent.series, series)
|
||||||
|
.innerJoin(series.member, member)
|
||||||
|
.where(baseWhereCondition(startDate, endDate, 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<GetAdminOriginalSeriesOwnerResponse> {
|
||||||
|
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<GetAdminOriginalSeriesSettlementDetailResponse>) {
|
||||||
|
items.forEachIndexed { index, item ->
|
||||||
|
val row = sheet.createRow(index + 1)
|
||||||
|
row.createCell(0).setCellValue(item.seriesTitle)
|
||||||
|
row.createCell(1).setCellValue(item.contentTitle)
|
||||||
|
row.createCell(2).setCellValue(item.price.toDouble())
|
||||||
|
row.createCell(3).setCellValue(item.orderType)
|
||||||
|
row.createCell(4).setCellValue(item.salesCount.toDouble())
|
||||||
|
row.createCell(5).setCellValue(item.totalCan.toDouble())
|
||||||
|
row.createCell(6).setCellValue(item.totalPoint.toDouble())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun toSheetName(owner: GetAdminOriginalSeriesOwnerResponse): String {
|
||||||
|
return WorkbookUtil.createSafeSheetName("${owner.memberId}_${owner.nickname}")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun toDateRange(startDateStr: String, endDateStr: String): Pair<LocalDateTime, LocalDateTime> {
|
||||||
|
val startDate = startDateStr.convertLocalDateTime()
|
||||||
|
val endDate = endDateStr.convertLocalDateTime(hour = 23, minute = 59, second = 59)
|
||||||
|
|
||||||
|
return startDate to endDate
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val EXCEL_HEADERS = listOf(
|
||||||
|
"시리즈 제목",
|
||||||
|
"콘텐츠 제목",
|
||||||
|
"가격(캔)",
|
||||||
|
"구분",
|
||||||
|
"판매 수",
|
||||||
|
"합계(캔)",
|
||||||
|
"합계(포인트)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.calculate.originalSeries
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty
|
||||||
|
import com.querydsl.core.annotations.QueryProjection
|
||||||
|
|
||||||
|
data class GetAdminOriginalSeriesOwnerResponse @QueryProjection constructor(
|
||||||
|
@JsonProperty("memberId") val memberId: Long,
|
||||||
|
@JsonProperty("nickname") val nickname: String
|
||||||
|
)
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.calculate.originalSeries
|
||||||
|
|
||||||
|
import com.querydsl.core.annotations.QueryProjection
|
||||||
|
import kr.co.vividnext.sodalive.content.order.OrderType
|
||||||
|
import kotlin.math.ceil
|
||||||
|
|
||||||
|
data class GetAdminOriginalSeriesSettlementDetailQueryData @QueryProjection constructor(
|
||||||
|
val seriesTitle: String,
|
||||||
|
val contentTitle: String,
|
||||||
|
val basePrice: Int,
|
||||||
|
val orderType: OrderType,
|
||||||
|
val salesCount: Long,
|
||||||
|
val totalCan: Int?,
|
||||||
|
val totalPoint: Int?
|
||||||
|
) {
|
||||||
|
fun toResponse(): GetAdminOriginalSeriesSettlementDetailResponse {
|
||||||
|
return GetAdminOriginalSeriesSettlementDetailResponse(
|
||||||
|
seriesTitle = seriesTitle,
|
||||||
|
contentTitle = contentTitle,
|
||||||
|
price = if (orderType == OrderType.RENTAL) ceil(basePrice * 0.7).toInt() else basePrice,
|
||||||
|
orderType = if (orderType == OrderType.RENTAL) "대여" else "소장",
|
||||||
|
salesCount = salesCount.toInt(),
|
||||||
|
totalCan = totalCan ?: 0,
|
||||||
|
totalPoint = totalPoint ?: 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package kr.co.vividnext.sodalive.admin.calculate.originalSeries
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty
|
||||||
|
|
||||||
|
data class GetAdminOriginalSeriesSettlementDetailResponse(
|
||||||
|
@JsonProperty("seriesTitle") val seriesTitle: String,
|
||||||
|
@JsonProperty("contentTitle") val contentTitle: String,
|
||||||
|
@JsonProperty("price") val price: Int,
|
||||||
|
@JsonProperty("orderType") val orderType: String,
|
||||||
|
@JsonProperty("salesCount") val salesCount: Int,
|
||||||
|
@JsonProperty("totalCan") val totalCan: Int,
|
||||||
|
@JsonProperty("totalPoint") val totalPoint: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
data class GetAdminOriginalSeriesSettlementDetailListResponse(
|
||||||
|
@JsonProperty("totalCount") val totalCount: Int,
|
||||||
|
@JsonProperty("items") val items: List<GetAdminOriginalSeriesSettlementDetailResponse>
|
||||||
|
)
|
||||||
@@ -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"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user