Compare commits

...

5 Commits

Author SHA1 Message Date
19bd07fe14 docs(original-series-calculate): 정산 API 파라미터와 응답 예시를 갱신한다 2026-04-21 19:16:58 +09:00
49b1aa8f0c fix(original-series-calculate): 소지 유저별 정산 엑셀 시트 생성을 정리한다 2026-04-21 19:16:34 +09:00
72f49f2471 refactor(original-series-calculate): 소지 유저 응답 식별자를 creatorId로 변경한다 2026-04-21 19:16:09 +09:00
5098994f4b refactor(original-series-calculate): 정산 조회 파라미터를 snake_case로 통일한다 2026-04-21 19:15:55 +09:00
6c49abc54e docs(original-series-calculate): 오리지널 시리즈 정산 API 명세를 보강한다
클라이언트 구현에 필요한 요청/응답 계약과 엑셀 시트 구성을 문서에 명확히 남긴다.
서버 API 계약을 문서만으로 그대로 사용할 수 있게 해 혼선을 줄인다.
2026-04-21 18:29:49 +09:00
8 changed files with 298 additions and 38 deletions

View File

@@ -3,7 +3,7 @@
- [x] 기존 관리자 정산 API 패턴(`/admin/calculate`, list/excel 쌍, `StreamingResponseBody`)을 유지하는 신규 패키지 구조를 확정한다. - [x] 기존 관리자 정산 API 패턴(`/admin/calculate`, list/excel 쌍, `StreamingResponseBody`)을 유지하는 신규 패키지 구조를 확정한다.
- [x] `kr.co.vividnext.sodalive.admin.calculate.originalSeries` 패키지에 오리지널 시리즈 소지 유저 조회 API를 추가한다. - [x] `kr.co.vividnext.sodalive.admin.calculate.originalSeries` 패키지에 오리지널 시리즈 소지 유저 조회 API를 추가한다.
- [x] `kr.co.vividnext.sodalive.admin.calculate.originalSeries` 패키지에 오리지널 시리즈 정산 내역 조회 API를 추가한다. - [x] `kr.co.vividnext.sodalive.admin.calculate.originalSeries` 패키지에 오리지널 시리즈 정산 내역 조회 API를 추가한다.
- [x] 정산 내역 조회 API가 `startDateStr`, `endDateStr`, `memberId`를 받아 KST 입력 날짜를 UTC 조회 범위로 변환하도록 구현한다. - [x] 정산 내역 조회 API가 `start_date`, `end_date`, `creator_id`를 받아 KST 입력 날짜를 UTC 조회 범위로 변환하도록 구현한다.
- [x] 정산 내역 조회 쿼리가 `Series(isOriginal = true) -> SeriesContent -> AudioContent -> Order` 경로를 사용해 `Content.id`, `Order.type` 기준으로 그룹화되도록 구현한다. - [x] 정산 내역 조회 쿼리가 `Series(isOriginal = true) -> SeriesContent -> AudioContent -> Order` 경로를 사용해 `Content.id`, `Order.type` 기준으로 그룹화되도록 구현한다.
- [x] 정산 내역 결과에 시리즈 타이틀, 콘텐츠 타이틀, 가격, 대여/소장 여부, 판매 수, 합계(캔), 합계(포인트)를 포함하도록 DTO를 추가한다. - [x] 정산 내역 결과에 시리즈 타이틀, 콘텐츠 타이틀, 가격, 대여/소장 여부, 판매 수, 합계(캔), 합계(포인트)를 포함하도록 DTO를 추가한다.
- [x] 가격은 주문에 저장된 값을 기준으로 사용하되, 대여는 `ceil(price * 0.7)` 규칙이 반영된 값으로 노출되도록 검증한다. - [x] 가격은 주문에 저장된 값을 기준으로 사용하되, 대여는 `ceil(price * 0.7)` 규칙이 반영된 값으로 노출되도록 검증한다.
@@ -16,8 +16,205 @@
- 오리지널 시리즈 여부는 `Series.isOriginal` 플래그를 기준으로 판단한다. - 오리지널 시리즈 여부는 `Series.isOriginal` 플래그를 기준으로 판단한다.
- 소지 유저는 현재 코드베이스 기준 `Series.member`를 의미하는 것으로 해석한다. - 소지 유저는 현재 코드베이스 기준 `Series.member`를 의미하는 것으로 해석한다.
- 날짜 입력 파라미터는 `startDate`, `endDate`, `memberId`로 받고, 날짜 값은 KST 기준 `00:00:00` / `23:59:59`로 해석한 뒤 `convertLocalDateTime()`으로 UTC `LocalDateTime`으로 변환한다. - 날짜 입력 파라미터는 `start_date`, `end_date`, `creator_id`로 받고, 날짜 값은 KST 기준 `00:00:00` / `23:59:59`로 해석한 뒤 `convertLocalDateTime()`으로 UTC `LocalDateTime`으로 변환한다.
- 정산 내역 조회 API는 `memberId`를 필수 필터로 사용하고, 엑셀 API는 전체 소지 유저를 순회해 시트를 생성하는 것으로 해석한다. - 정산 내역 조회 API는 `creator_id`를 필수 필터로 사용하고, 엑셀 API는 전체 소지 유저를 순회해 시트를 생성하는 것으로 해석한다.
## API 명세
### 1. 오리지널 시리즈 소지 유저 조회
#### URI
- `GET /admin/calculate/original-series/owners`
#### Request
- Header
- 인증: 관리자 권한 필요 (`hasRole('ADMIN')`)
- Query Parameter
- 없음
- Body
- 없음
#### Response
- Content-Type: `application/json`
- 응답 구조
```json
{
"success": true,
"message": null,
"data": [
{
"creatorId": 1,
"nickname": "owner-a"
},
{
"creatorId": 2,
"nickname": "owner-b"
}
],
"errorProperty": null
}
```
#### Response Field
- `success`: 성공 여부
- `message`: 성공 메시지, 현재 구현에서는 `null`
- `data`: 오리지널 시리즈 소지 유저 목록
- `creatorId`: 정산 내역 조회에 사용할 크리에이터 ID
- `nickname`: 관리자 화면에 노출할 닉네임
- `errorProperty`: 에러 시 사용되는 필드, 성공 시 `null`
### 2. 오리지널 시리즈 정산 내역 조회
#### URI
- `GET /admin/calculate/original-series/settlement-details`
#### Request
- Header
- 인증: 관리자 권한 필요 (`hasRole('ADMIN')`)
- Query Parameter
- `start_date`: 시작일, 형식 `yyyy-MM-dd`, KST 기준
- `end_date`: 종료일, 형식 `yyyy-MM-dd`, KST 기준
- `creator_id`: 오리지널 시리즈 소지 유저 ID
- `page`: 페이지 번호, Spring `Pageable` 규칙 사용
- `size`: 페이지 크기, Spring `Pageable` 규칙 사용
- Body
- 없음
#### Request Example
```text
GET /admin/calculate/original-series/settlement-details?start_date=2026-04-01&end_date=2026-04-30&creator_id=1&page=0&size=20
```
#### Response
- Content-Type: `application/json`
- 응답 구조
```json
{
"success": true,
"message": null,
"data": {
"totalCount": 2,
"items": [
{
"seriesTitle": "오리지널 시리즈",
"contentTitle": "1화",
"price": 70,
"orderType": "대여",
"salesCount": 2,
"totalCan": 130,
"totalPoint": 100
},
{
"seriesTitle": "오리지널 시리즈",
"contentTitle": "1화",
"price": 100,
"orderType": "소장",
"salesCount": 2,
"totalCan": 200,
"totalPoint": 25
}
]
},
"errorProperty": null
}
```
#### Response Field
- `data.totalCount`: 조회 결과 전체 건수
- `data.items`: 정산 내역 목록
- `seriesTitle`: 시리즈 제목
- `contentTitle`: 작품 화 제목
- `price`: 표시 가격(캔)
- `소장`: `audioContent.price`
- `대여`: `ceil(audioContent.price * 0.7)`
- `orderType`: `대여` 또는 `소장`
- `salesCount`: 판매 건수
- `totalCan`: 합계 캔
- `totalPoint`: 합계 포인트
### 3. 오리지널 시리즈 정산 내역 엑셀 다운로드
#### URI
- `GET /admin/calculate/original-series/settlement-details/excel`
#### Request
- Header
- 인증: 관리자 권한 필요 (`hasRole('ADMIN')`)
- Query Parameter
- `start_date`: 시작일, 형식 `yyyy-MM-dd`, KST 기준
- `end_date`: 종료일, 형식 `yyyy-MM-dd`, KST 기준
- Body
- 없음
#### Request Example
```text
GET /admin/calculate/original-series/settlement-details/excel?start_date=2026-04-01&end_date=2026-04-30
```
#### Response
- Content-Type: `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`
- Header
- `Content-Disposition: attachment; filename*=UTF-8''original-series-settlement-details.xlsx`
- Body
- `.xlsx` 바이너리 스트림
#### Excel 구성
- 오리지널 시리즈 소지 유저별로 시트 1개 생성
- 시트명 형식: `{nickname}`
- 각 시트의 헤더 열 순서
1. `시리즈 제목`
2. `콘텐츠 제목`
3. `가격(캔)`
4. `구분`
5. `판매 수`
6. `합계(캔)`
7. `합계(포인트)`
- 해당 기간에 데이터가 없는 유저도 헤더만 있는 시트를 유지
## 클라이언트 기능 구현용 프롬프트
```text
관리자 페이지에서 오리지널 시리즈 작품 화별 정산 내역을 조회하고 엑셀 다운로드하는 클라이언트 기능을 구현한다.
필수 API 계약은 아래와 같다.
1. 소지 유저 조회
- GET /admin/calculate/original-series/owners
- 응답은 ApiResponse<List<{ creatorId: number; nickname: string }>> 형태다.
2. 정산 내역 조회
- GET /admin/calculate/original-series/settlement-details
- query: start_date(yyyy-MM-dd), end_date(yyyy-MM-dd), creator_id(number), page(number), size(number)
- 응답은 ApiResponse<{ totalCount: number; items: Array<{ seriesTitle: string; contentTitle: string; price: number; orderType: string; salesCount: number; totalCan: number; totalPoint: number }> }> 형태다.
3. 엑셀 다운로드
- GET /admin/calculate/original-series/settlement-details/excel
- query: start_date(yyyy-MM-dd), end_date(yyyy-MM-dd)
- 응답은 xlsx 바이너리이며 파일명은 `original-series-settlement-details.xlsx` 이다.
구현 요구사항:
- 화면 진입 시 owners API를 먼저 호출해 멤버 선택 드롭다운 데이터를 로드한다.
- 사용자가 start_date, end_date, creator_id를 선택한 뒤 정산 내역 조회 API를 호출한다.
- 목록 테이블에는 시리즈 제목, 콘텐츠 제목, 가격, 구분, 판매 수, 합계(캔), 합계(포인트)를 그대로 표시한다.
- 페이지네이션은 page/size 기반으로 처리하고 totalCount를 사용해 총 페이지 수를 계산한다.
- 엑셀 다운로드 버튼은 현재 선택된 start_date/end_date만 사용해 excel API를 호출한다.
- JSON 응답은 항상 ApiResponse 래퍼의 success/data/message/errorProperty를 기준으로 처리한다.
- success가 false이면 message를 우선 노출한다.
- 날짜는 문자열 `yyyy-MM-dd` 형식으로 서버에 전달한다.
산출물 요구사항:
- API 호출 함수
- 타입 정의
- 목록 조회 상태 관리
- 엑셀 다운로드 처리
- 에러 처리 로직
임의로 API 계약을 바꾸지 말고, 위 URI/Request/Response를 그대로 사용한다.
```
## 검증 기록 ## 검증 기록
@@ -35,3 +232,13 @@
- `./gradlew ktlintCheck` → 성공 - `./gradlew ktlintCheck` → 성공
- `./gradlew build` → 성공 - `./gradlew build` → 성공
- `./gradlew test --tests "*AdminOriginalSeriesCalculateServiceTest.shouldCreateOneSheetPerOwnerForExcel"` → 성공 - `./gradlew test --tests "*AdminOriginalSeriesCalculateServiceTest.shouldCreateOneSheetPerOwnerForExcel"` → 성공
### 2차 문서화
- 무엇을: 기존 작업 계획 문서에 오리지널 시리즈 정산 API 3종의 URI, Request, Response 상세 명세와 클라이언트 기능 구현용 프롬프트를 추가했다.
- 왜: 클라이언트 기능 구현 시 서버 API 계약을 문서만 보고 그대로 사용할 수 있어야 하기 때문이다.
- 어떻게:
- `AdminOriginalSeriesCalculateController`, 응답 DTO, `ApiResponse` 구조를 기준으로 문서에 JSON 예시와 필드 설명을 정리했다.
- 엑셀 다운로드 API는 바이너리 응답과 헤더 규격, 시트 구성 규칙을 함께 기록했다.
- 클라이언트 개발자가 그대로 복사해 사용할 수 있도록 API 호출 순서와 상태 처리 조건을 포함한 프롬프트를 추가했다.
- 실행 결과:
- 수정 문서 재확인(`read`) → 성공

View File

@@ -25,15 +25,15 @@ class AdminOriginalSeriesCalculateController(
@GetMapping("/original-series/settlement-details") @GetMapping("/original-series/settlement-details")
fun getSettlementDetails( fun getSettlementDetails(
@RequestParam("startDate") startDateStr: String, @RequestParam("start_date") startDateStr: String,
@RequestParam("endDate") endDateStr: String, @RequestParam("end_date") endDateStr: String,
@RequestParam memberId: Long, @RequestParam("creator_id") creatorId: Long,
pageable: Pageable pageable: Pageable
) = ApiResponse.ok( ) = ApiResponse.ok(
service.getSettlementDetails( service.getSettlementDetails(
startDateStr = startDateStr, startDateStr = startDateStr,
endDateStr = endDateStr, endDateStr = endDateStr,
memberId = memberId, creatorId = creatorId,
offset = pageable.offset, offset = pageable.offset,
limit = pageable.pageSize.toLong() limit = pageable.pageSize.toLong()
) )
@@ -41,8 +41,8 @@ class AdminOriginalSeriesCalculateController(
@GetMapping("/original-series/settlement-details/excel") @GetMapping("/original-series/settlement-details/excel")
fun downloadSettlementDetailsExcel( fun downloadSettlementDetailsExcel(
@RequestParam("startDate") startDateStr: String, @RequestParam("start_date") startDateStr: String,
@RequestParam("endDate") endDateStr: String @RequestParam("end_date") endDateStr: String
): ResponseEntity<StreamingResponseBody> = createExcelResponse( ): ResponseEntity<StreamingResponseBody> = createExcelResponse(
fileName = "original-series-settlement-details.xlsx", fileName = "original-series-settlement-details.xlsx",
response = service.downloadSettlementDetailsExcel(startDateStr, endDateStr) response = service.downloadSettlementDetailsExcel(startDateStr, endDateStr)

View File

@@ -37,7 +37,7 @@ class AdminOriginalSeriesCalculateQueryRepository(
fun getSettlementDetailTotalCount( fun getSettlementDetailTotalCount(
startDate: LocalDateTime, startDate: LocalDateTime,
endDate: LocalDateTime, endDate: LocalDateTime,
memberId: Long creatorId: Long
): Int { ): Int {
return queryFactory return queryFactory
.select(audioContent.id) .select(audioContent.id)
@@ -46,7 +46,7 @@ class AdminOriginalSeriesCalculateQueryRepository(
.innerJoin(seriesContent).on(seriesContent.content.id.eq(audioContent.id)) .innerJoin(seriesContent).on(seriesContent.content.id.eq(audioContent.id))
.innerJoin(seriesContent.series, series) .innerJoin(seriesContent.series, series)
.innerJoin(series.member, member) .innerJoin(series.member, member)
.where(baseWhereCondition(startDate, endDate, memberId)) .where(baseWhereCondition(startDate, endDate, creatorId))
.groupBy( .groupBy(
series.id, series.id,
series.title, series.title,
@@ -62,7 +62,7 @@ class AdminOriginalSeriesCalculateQueryRepository(
fun getSettlementDetails( fun getSettlementDetails(
startDate: LocalDateTime, startDate: LocalDateTime,
endDate: LocalDateTime, endDate: LocalDateTime,
memberId: Long, creatorId: Long,
offset: Long, offset: Long,
limit: Long limit: Long
): List<GetAdminOriginalSeriesSettlementDetailQueryData> { ): List<GetAdminOriginalSeriesSettlementDetailQueryData> {
@@ -83,7 +83,7 @@ class AdminOriginalSeriesCalculateQueryRepository(
.innerJoin(seriesContent).on(seriesContent.content.id.eq(audioContent.id)) .innerJoin(seriesContent).on(seriesContent.content.id.eq(audioContent.id))
.innerJoin(seriesContent.series, series) .innerJoin(seriesContent.series, series)
.innerJoin(series.member, member) .innerJoin(series.member, member)
.where(baseWhereCondition(startDate, endDate, memberId)) .where(baseWhereCondition(startDate, endDate, creatorId))
.groupBy( .groupBy(
series.id, series.id,
series.title, series.title,
@@ -101,12 +101,12 @@ class AdminOriginalSeriesCalculateQueryRepository(
private fun baseWhereCondition( private fun baseWhereCondition(
startDate: LocalDateTime, startDate: LocalDateTime,
endDate: LocalDateTime, endDate: LocalDateTime,
memberId: Long creatorId: Long
): BooleanExpression { ): BooleanExpression {
return series.isOriginal.isTrue return series.isOriginal.isTrue
.and(series.isActive.isTrue) .and(series.isActive.isTrue)
.and(member.isActive.isTrue) .and(member.isActive.isTrue)
.and(member.id.eq(memberId)) .and(member.id.eq(creatorId))
.and(order.isActive.isTrue) .and(order.isActive.isTrue)
.and(order.createdAt.goe(startDate)) .and(order.createdAt.goe(startDate))
.and(order.createdAt.loe(endDate)) .and(order.createdAt.loe(endDate))

View File

@@ -22,14 +22,14 @@ class AdminOriginalSeriesCalculateService(
fun getSettlementDetails( fun getSettlementDetails(
startDateStr: String, startDateStr: String,
endDateStr: String, endDateStr: String,
memberId: Long, creatorId: Long,
offset: Long, offset: Long,
limit: Long limit: Long
): GetAdminOriginalSeriesSettlementDetailListResponse { ): GetAdminOriginalSeriesSettlementDetailListResponse {
val (startDate, endDate) = toDateRange(startDateStr, endDateStr) val (startDate, endDate) = toDateRange(startDateStr, endDateStr)
val totalCount = repository.getSettlementDetailTotalCount(startDate, endDate, memberId) val totalCount = repository.getSettlementDetailTotalCount(startDate, endDate, creatorId)
val items = repository val items = repository
.getSettlementDetails(startDate, endDate, memberId, offset, limit) .getSettlementDetails(startDate, endDate, creatorId, offset, limit)
.map { it.toResponse() } .map { it.toResponse() }
return GetAdminOriginalSeriesSettlementDetailListResponse(totalCount, items) return GetAdminOriginalSeriesSettlementDetailListResponse(totalCount, items)
@@ -43,18 +43,16 @@ class AdminOriginalSeriesCalculateService(
return StreamingResponseBody { outputStream -> return StreamingResponseBody { outputStream ->
val workbook = SXSSFWorkbook(100) val workbook = SXSSFWorkbook(100)
try { try {
if (owners.isEmpty()) { if (owners.isNotEmpty()) {
writeHeaders(workbook.createSheet("오리지널 시리즈 정산"))
} else {
owners.forEach { owner -> owners.forEach { owner ->
val sheet = workbook.createSheet(toSheetName(owner)) val sheet = workbook.createSheet(toSheetName(owner))
writeHeaders(sheet) writeHeaders(sheet)
val totalCount = repository.getSettlementDetailTotalCount(startDate, endDate, owner.memberId) val totalCount = repository.getSettlementDetailTotalCount(startDate, endDate, owner.creatorId)
val items = if (totalCount == 0) { val items = if (totalCount == 0) {
emptyList() emptyList()
} else { } else {
repository.getSettlementDetails(startDate, endDate, owner.memberId, 0L, totalCount.toLong()) repository.getSettlementDetails(startDate, endDate, owner.creatorId, 0L, totalCount.toLong())
.map { it.toResponse() } .map { it.toResponse() }
} }
@@ -92,7 +90,7 @@ class AdminOriginalSeriesCalculateService(
} }
private fun toSheetName(owner: GetAdminOriginalSeriesOwnerResponse): String { private fun toSheetName(owner: GetAdminOriginalSeriesOwnerResponse): String {
return WorkbookUtil.createSafeSheetName("${owner.memberId}_${owner.nickname}") return WorkbookUtil.createSafeSheetName(owner.nickname)
} }
private fun toDateRange(startDateStr: String, endDateStr: String): Pair<LocalDateTime, LocalDateTime> { private fun toDateRange(startDateStr: String, endDateStr: String): Pair<LocalDateTime, LocalDateTime> {

View File

@@ -4,6 +4,6 @@ import com.fasterxml.jackson.annotation.JsonProperty
import com.querydsl.core.annotations.QueryProjection import com.querydsl.core.annotations.QueryProjection
data class GetAdminOriginalSeriesOwnerResponse @QueryProjection constructor( data class GetAdminOriginalSeriesOwnerResponse @QueryProjection constructor(
@JsonProperty("memberId") val memberId: Long, @JsonProperty("creatorId") val creatorId: Long,
@JsonProperty("nickname") val nickname: String @JsonProperty("nickname") val nickname: String
) )

View File

@@ -23,13 +23,14 @@ class AdminOriginalSeriesCalculateControllerTest {
@Test @Test
@DisplayName("관리자 컨트롤러는 오리지널 시리즈 소지 유저 목록을 반환한다") @DisplayName("관리자 컨트롤러는 오리지널 시리즈 소지 유저 목록을 반환한다")
fun shouldReturnOriginalSeriesOwners() { fun shouldReturnOriginalSeriesOwners() {
val owners = listOf(GetAdminOriginalSeriesOwnerResponse(memberId = 1L, nickname = "owner-a")) val owners = listOf(GetAdminOriginalSeriesOwnerResponse(creatorId = 1L, nickname = "owner-a"))
Mockito.`when`(service.getOriginalSeriesOwners()).thenReturn(owners) Mockito.`when`(service.getOriginalSeriesOwners()).thenReturn(owners)
val response = controller.getOriginalSeriesOwners() val response = controller.getOriginalSeriesOwners()
assertEquals(true, response.success) assertEquals(true, response.success)
assertEquals(1, response.data!!.size) assertEquals(1, response.data!!.size)
assertEquals(1L, response.data!![0].creatorId)
assertEquals("owner-a", response.data!![0].nickname) assertEquals("owner-a", response.data!![0].nickname)
Mockito.verify(service).getOriginalSeriesOwners() Mockito.verify(service).getOriginalSeriesOwners()
} }
@@ -55,7 +56,7 @@ class AdminOriginalSeriesCalculateControllerTest {
service.getSettlementDetails( service.getSettlementDetails(
startDateStr = "2026-04-01", startDateStr = "2026-04-01",
endDateStr = "2026-04-30", endDateStr = "2026-04-30",
memberId = 5L, creatorId = 5L,
offset = 10L, offset = 10L,
limit = 10L limit = 10L
) )
@@ -64,7 +65,7 @@ class AdminOriginalSeriesCalculateControllerTest {
val response = controller.getSettlementDetails( val response = controller.getSettlementDetails(
startDateStr = "2026-04-01", startDateStr = "2026-04-01",
endDateStr = "2026-04-30", endDateStr = "2026-04-30",
memberId = 5L, creatorId = 5L,
pageable = PageRequest.of(1, 10) pageable = PageRequest.of(1, 10)
) )
@@ -74,12 +75,47 @@ class AdminOriginalSeriesCalculateControllerTest {
Mockito.verify(service).getSettlementDetails( Mockito.verify(service).getSettlementDetails(
startDateStr = "2026-04-01", startDateStr = "2026-04-01",
endDateStr = "2026-04-30", endDateStr = "2026-04-30",
memberId = 5L, creatorId = 5L,
offset = 10L, offset = 10L,
limit = 10L limit = 10L
) )
} }
@Test
@DisplayName("정산 내역 조회 API는 snake_case 파라미터를 사용한다")
fun shouldUseSnakeCaseQueryParameterNames() {
val method = AdminOriginalSeriesCalculateController::class.java
.getDeclaredMethod(
"getSettlementDetails",
String::class.java,
String::class.java,
java.lang.Long.TYPE,
org.springframework.data.domain.Pageable::class.java
)
val parameters = method.parameters
val requestParamClass = org.springframework.web.bind.annotation.RequestParam::class.java
assertEquals("start_date", parameters[0].getAnnotation(requestParamClass).value)
assertEquals("end_date", parameters[1].getAnnotation(requestParamClass).value)
assertEquals("creator_id", parameters[2].getAnnotation(requestParamClass).value)
}
@Test
@DisplayName("정산 엑셀 다운로드 API는 snake_case 파라미터를 사용한다")
fun shouldUseSnakeCaseQueryParameterNamesForExcel() {
val method = AdminOriginalSeriesCalculateController::class.java
.getDeclaredMethod(
"downloadSettlementDetailsExcel",
String::class.java,
String::class.java
)
val parameters = method.parameters
val requestParamClass = org.springframework.web.bind.annotation.RequestParam::class.java
assertEquals("start_date", parameters[0].getAnnotation(requestParamClass).value)
assertEquals("end_date", parameters[1].getAnnotation(requestParamClass).value)
}
@Test @Test
@DisplayName("관리자 컨트롤러는 오리지널 시리즈 정산 엑셀을 다운로드한다") @DisplayName("관리자 컨트롤러는 오리지널 시리즈 정산 엑셀을 다운로드한다")
fun shouldDownloadOriginalSeriesSettlementExcel() { fun shouldDownloadOriginalSeriesSettlementExcel() {

View File

@@ -54,8 +54,8 @@ class AdminOriginalSeriesCalculateQueryRepositoryTest @Autowired constructor(
val result = repository.getOriginalSeriesOwners() val result = repository.getOriginalSeriesOwners()
assertEquals(2, result.size) assertEquals(2, result.size)
assertEquals(ownerA.id, result[0].memberId) assertEquals(ownerA.id, result[0].creatorId)
assertEquals(ownerB.id, result[1].memberId) assertEquals(ownerB.id, result[1].creatorId)
} }
@Test @Test

View File

@@ -25,13 +25,13 @@ class AdminOriginalSeriesCalculateServiceTest {
@Test @Test
@DisplayName("오리지널 시리즈 소지 유저 목록 조회는 리포지토리 결과를 반환한다") @DisplayName("오리지널 시리즈 소지 유저 목록 조회는 리포지토리 결과를 반환한다")
fun shouldReturnOriginalSeriesOwners() { fun shouldReturnOriginalSeriesOwners() {
val owners = listOf(GetAdminOriginalSeriesOwnerResponse(memberId = 1L, nickname = "owner-a")) val owners = listOf(GetAdminOriginalSeriesOwnerResponse(creatorId = 1L, nickname = "owner-a"))
Mockito.`when`(repository.getOriginalSeriesOwners()).thenReturn(owners) Mockito.`when`(repository.getOriginalSeriesOwners()).thenReturn(owners)
val result = service.getOriginalSeriesOwners() val result = service.getOriginalSeriesOwners()
assertEquals(1, result.size) assertEquals(1, result.size)
assertEquals(1L, result[0].memberId) assertEquals(1L, result[0].creatorId)
Mockito.verify(repository).getOriginalSeriesOwners() Mockito.verify(repository).getOriginalSeriesOwners()
} }
@@ -67,7 +67,7 @@ class AdminOriginalSeriesCalculateServiceTest {
val result = service.getSettlementDetails( val result = service.getSettlementDetails(
startDateStr = "2026-04-01", startDateStr = "2026-04-01",
endDateStr = "2026-04-30", endDateStr = "2026-04-30",
memberId = 9L, creatorId = 9L,
offset = 0L, offset = 0L,
limit = 20L limit = 20L
) )
@@ -96,8 +96,8 @@ class AdminOriginalSeriesCalculateServiceTest {
@DisplayName("정산 엑셀 다운로드는 소지 유저별 시트를 생성한다") @DisplayName("정산 엑셀 다운로드는 소지 유저별 시트를 생성한다")
fun shouldCreateOneSheetPerOwnerForExcel() { fun shouldCreateOneSheetPerOwnerForExcel() {
val owners = listOf( val owners = listOf(
GetAdminOriginalSeriesOwnerResponse(memberId = 1L, nickname = "owner-a"), GetAdminOriginalSeriesOwnerResponse(creatorId = 1L, nickname = "owner-a"),
GetAdminOriginalSeriesOwnerResponse(memberId = 2L, nickname = "owner-b") GetAdminOriginalSeriesOwnerResponse(creatorId = 2L, nickname = "owner-b")
) )
Mockito.`when`(repository.getOriginalSeriesOwners()).thenReturn(owners) Mockito.`when`(repository.getOriginalSeriesOwners()).thenReturn(owners)
Mockito.`when`( Mockito.`when`(
@@ -147,8 +147,8 @@ class AdminOriginalSeriesCalculateServiceTest {
XSSFWorkbook(ByteArrayInputStream(outputStream.toByteArray())).use { workbook -> XSSFWorkbook(ByteArrayInputStream(outputStream.toByteArray())).use { workbook ->
assertEquals(2, workbook.numberOfSheets) assertEquals(2, workbook.numberOfSheets)
assertEquals("1_owner-a", workbook.getSheetAt(0).sheetName) assertEquals("owner-a", workbook.getSheetAt(0).sheetName)
assertEquals("2_owner-b", workbook.getSheetAt(1).sheetName) assertEquals("owner-b", workbook.getSheetAt(1).sheetName)
assertEquals("시리즈 제목", workbook.getSheetAt(0).getRow(0).getCell(0).stringCellValue) assertEquals("시리즈 제목", workbook.getSheetAt(0).getRow(0).getCell(0).stringCellValue)
assertEquals("오리지널 시리즈", workbook.getSheetAt(0).getRow(1).getCell(0).stringCellValue) assertEquals("오리지널 시리즈", workbook.getSheetAt(0).getRow(1).getCell(0).stringCellValue)
assertEquals("시리즈 제목", workbook.getSheetAt(1).getRow(0).getCell(0).stringCellValue) assertEquals("시리즈 제목", workbook.getSheetAt(1).getRow(0).getCell(0).stringCellValue)
@@ -164,4 +164,23 @@ class AdminOriginalSeriesCalculateServiceTest {
1L 1L
) )
} }
@Test
@DisplayName("소지 유저가 없으면 엑셀에 시트를 생성하지 않는다")
fun shouldCreateWorkbookWithoutSheetsWhenNoOwners() {
Mockito.`when`(repository.getOriginalSeriesOwners()).thenReturn(emptyList())
val response = service.downloadSettlementDetailsExcel(
startDateStr = "2026-04-01",
endDateStr = "2026-04-30"
)
val outputStream = ByteArrayOutputStream()
response.writeTo(outputStream)
assertTrue(outputStream.toByteArray().isNotEmpty())
XSSFWorkbook(ByteArrayInputStream(outputStream.toByteArray())).use { workbook ->
assertEquals(0, workbook.numberOfSheets)
}
}
} }