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