From bf67dab6a4919fbf8078182100f28627d9b0362c Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 10 Apr 2026 02:24:08 +0900 Subject: [PATCH] =?UTF-8?q?feat(agent-calculate):=20=EC=97=90=EC=9D=B4?= =?UTF-8?q?=EC=A0=84=ED=8A=B8=EB=B3=84=20=EC=A0=95=EC=82=B0=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EA=B8=B0=EB=8A=A5=EC=9D=84=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../calculate/AgentCalculateController.kt | 127 +++ .../AgentCalculateQueryRepository.kt | 666 +++++++++++++++ .../agent/calculate/AgentCalculateService.kt | 219 +++++ .../GetAgentAssignedCreatorResponse.kt | 13 + ...nnelDonationSettlementByCreatorResponse.kt | 102 +++ ...tAgentCreatorSettlementSummaryQueryData.kt | 77 ++ .../GetAgentSettlementByCreatorResponse.kt | 44 + .../calculate/AgentCalculateControllerTest.kt | 326 ++++++++ .../AgentCalculateQueryRepositoryTest.kt | 777 ++++++++++++++++++ .../calculate/AgentCalculateServiceTest.kt | 633 ++++++++++++++ 10 files changed, 2984 insertions(+) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateController.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateQueryRepository.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateService.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/GetAgentAssignedCreatorResponse.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/GetAgentChannelDonationSettlementByCreatorResponse.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/GetAgentCreatorSettlementSummaryQueryData.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/GetAgentSettlementByCreatorResponse.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateControllerTest.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateQueryRepositoryTest.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateServiceTest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateController.kt new file mode 100644 index 00000000..e802e4f5 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateController.kt @@ -0,0 +1,127 @@ +package kr.co.vividnext.sodalive.partner.agent.calculate + +import kr.co.vividnext.sodalive.common.ApiResponse +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.member.Member +import org.springframework.data.domain.Pageable +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.security.core.annotation.AuthenticationPrincipal +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 + +@RestController +@PreAuthorize("hasRole('AGENT')") +@RequestMapping("/agent/calculate") +class AgentCalculateController(private val service: AgentCalculateService) { + @GetMapping("/creator/list") + fun getAssignedCreators( + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, + pageable: Pageable + ) = run { + val agent = member ?: throw SodaException(messageKey = "common.error.bad_credentials") + ApiResponse.ok( + service.getAssignedCreators( + agentId = agent.id!!, + offset = pageable.offset, + limit = pageable.pageSize.toLong() + ) + ) + } + + @GetMapping("/live-by-creator") + fun getCalculateLiveByCreator( + @RequestParam startDateStr: String, + @RequestParam endDateStr: String, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, + pageable: Pageable + ) = run { + val agent = member ?: throw SodaException(messageKey = "common.error.bad_credentials") + ApiResponse.ok( + service.getCalculateLiveByCreator( + startDateStr = startDateStr, + endDateStr = endDateStr, + agentId = agent.id!!, + offset = pageable.offset, + limit = pageable.pageSize.toLong() + ) + ) + } + + @GetMapping("/content-by-creator") + fun getCalculateContentByCreator( + @RequestParam startDateStr: String, + @RequestParam endDateStr: String, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, + pageable: Pageable + ) = run { + val agent = member ?: throw SodaException(messageKey = "common.error.bad_credentials") + ApiResponse.ok( + service.getCalculateContentByCreator( + startDateStr = startDateStr, + endDateStr = endDateStr, + agentId = agent.id!!, + offset = pageable.offset, + limit = pageable.pageSize.toLong() + ) + ) + } + + @GetMapping("/community-by-creator") + fun getCalculateCommunityByCreator( + @RequestParam startDateStr: String, + @RequestParam endDateStr: String, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, + pageable: Pageable + ) = run { + val agent = member ?: throw SodaException(messageKey = "common.error.bad_credentials") + ApiResponse.ok( + service.getCalculateCommunityByCreator( + startDateStr = startDateStr, + endDateStr = endDateStr, + agentId = agent.id!!, + offset = pageable.offset, + limit = pageable.pageSize.toLong() + ) + ) + } + + @GetMapping("/channel-donation-by-creator") + fun getChannelDonationByCreator( + @RequestParam startDateStr: String, + @RequestParam endDateStr: String, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, + pageable: Pageable + ) = run { + val agent = member ?: throw SodaException(messageKey = "common.error.bad_credentials") + ApiResponse.ok( + service.getChannelDonationByCreator( + startDateStr = startDateStr, + endDateStr = endDateStr, + agentId = agent.id!!, + offset = pageable.offset, + limit = pageable.pageSize.toLong() + ) + ) + } + + @GetMapping("/content-donation-by-creator") + fun getCalculateContentDonationByCreator( + @RequestParam startDateStr: String, + @RequestParam endDateStr: String, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, + pageable: Pageable + ) = run { + val agent = member ?: throw SodaException(messageKey = "common.error.bad_credentials") + ApiResponse.ok( + service.getCalculateContentDonationByCreator( + startDateStr = startDateStr, + endDateStr = endDateStr, + agentId = agent.id!!, + offset = pageable.offset, + limit = pageable.pageSize.toLong() + ) + ) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateQueryRepository.kt new file mode 100644 index 00000000..a17351b3 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateQueryRepository.kt @@ -0,0 +1,666 @@ +package kr.co.vividnext.sodalive.partner.agent.calculate + +import com.querydsl.core.types.dsl.DateTimeExpression +import com.querydsl.core.types.dsl.Expressions +import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.admin.calculate.ratio.QCreatorSettlementRatio.creatorSettlementRatio +import kr.co.vividnext.sodalive.can.use.CanUsage +import kr.co.vividnext.sodalive.can.use.QUseCan.useCan +import kr.co.vividnext.sodalive.can.use.QUseCanCalculate.useCanCalculate +import kr.co.vividnext.sodalive.can.use.UseCanCalculateStatus +import kr.co.vividnext.sodalive.content.QAudioContent.audioContent +import kr.co.vividnext.sodalive.content.order.QOrder.order +import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.QCreatorCommunity.creatorCommunity +import kr.co.vividnext.sodalive.live.room.QLiveRoom.liveRoom +import kr.co.vividnext.sodalive.member.QMember.member +import kr.co.vividnext.sodalive.partner.agent.assignment.QAgentCreatorRelation.agentCreatorRelation +import kr.co.vividnext.sodalive.partner.agent.ratio.QAgentSettlementRatio.agentSettlementRatio +import org.springframework.stereotype.Repository +import java.time.LocalDateTime + +@Repository +class AgentCalculateQueryRepository(private val queryFactory: JPAQueryFactory) { + fun getAssignedCreatorTotalCount(agentId: Long, currentTime: LocalDateTime): Int { + return queryFactory + .select(agentCreatorRelation.id.count()) + .from(agentCreatorRelation) + .where( + assignedToAgentAtCurrentTime(agentId = agentId, currentTime = currentTime) + ) + .fetchOne() + ?.toInt() + ?: 0 + } + + fun getAssignedCreators( + agentId: Long, + offset: Long, + limit: Long, + currentTime: LocalDateTime + ): List { + return queryFactory + .select( + QGetAgentAssignedCreatorItem( + agentCreatorRelation.creator.id, + agentCreatorRelation.creator.nickname + ) + ) + .from(agentCreatorRelation) + .where( + assignedToAgentAtCurrentTime(agentId = agentId, currentTime = currentTime) + ) + .orderBy(agentCreatorRelation.creator.id.desc()) + .offset(offset) + .limit(limit) + .fetch() + } + + fun getCalculateLiveByCreatorTotalCount(startDate: LocalDateTime, endDate: LocalDateTime, agentId: Long): Int { + return queryFactory + .select(member.id.countDistinct()) + .from(useCan) + .innerJoin(useCan.room, liveRoom) + .innerJoin(liveRoom.member, member) + .innerJoin(agentCreatorRelation) + .on( + agentCreatorRelation.creator.id.eq(member.id) + .and(assignedToAgentAtEventTime(agentId, useCan.createdAt)) + ) + .where( + useCan.isRefund.isFalse + .and(useCan.createdAt.goe(startDate)) + .and(useCan.createdAt.loe(endDate)) + ) + .fetchOne() + ?.toInt() + ?: 0 + } + + fun getCalculateLiveByCreator( + startDate: LocalDateTime, + endDate: LocalDateTime, + agentId: Long + ): List { + return getCalculateLiveByCreatorRows(startDate, endDate, agentId, creatorIds = null) + } + + fun getCalculateLiveByCreator( + startDate: LocalDateTime, + endDate: LocalDateTime, + agentId: Long, + offset: Long, + limit: Long + ): List { + val creatorIds = queryFactory + .select(member.id) + .from(useCan) + .innerJoin(useCan.room, liveRoom) + .innerJoin(liveRoom.member, member) + .innerJoin(agentCreatorRelation) + .on( + agentCreatorRelation.creator.id.eq(member.id) + .and(assignedToAgentAtEventTime(agentId, useCan.createdAt)) + ) + .where( + useCan.isRefund.isFalse + .and(useCan.createdAt.goe(startDate)) + .and(useCan.createdAt.loe(endDate)) + ) + .groupBy(member.id) + .orderBy(member.id.desc()) + .offset(offset) + .limit(limit) + .fetch() + + if (creatorIds.isEmpty()) { + return emptyList() + } + + return getCalculateLiveByCreatorRows(startDate, endDate, agentId, creatorIds) + } + + fun getCalculateContentByCreatorTotalCount(startDate: LocalDateTime, endDate: LocalDateTime, agentId: Long): Int { + return queryFactory + .select(member.id.countDistinct()) + .from(order) + .innerJoin(order.audioContent, audioContent) + .innerJoin(audioContent.member, member) + .innerJoin(agentCreatorRelation) + .on( + agentCreatorRelation.creator.id.eq(member.id) + .and(assignedToAgentAtEventTime(agentId, order.createdAt)) + ) + .where( + order.createdAt.goe(startDate) + .and(order.createdAt.loe(endDate)) + .and(order.isActive.isTrue) + ) + .fetchOne() + ?.toInt() + ?: 0 + } + + fun getCalculateContentByCreator( + startDate: LocalDateTime, + endDate: LocalDateTime, + agentId: Long + ): List { + return getCalculateContentByCreatorRows(startDate, endDate, agentId, creatorIds = null) + } + + fun getCalculateContentByCreator( + startDate: LocalDateTime, + endDate: LocalDateTime, + agentId: Long, + offset: Long, + limit: Long + ): List { + val creatorIds = queryFactory + .select(member.id) + .from(order) + .innerJoin(order.audioContent, audioContent) + .innerJoin(audioContent.member, member) + .innerJoin(agentCreatorRelation) + .on( + agentCreatorRelation.creator.id.eq(member.id) + .and(assignedToAgentAtEventTime(agentId, order.createdAt)) + ) + .where( + order.createdAt.goe(startDate) + .and(order.createdAt.loe(endDate)) + .and(order.isActive.isTrue) + ) + .groupBy(member.id) + .orderBy(member.id.desc()) + .offset(offset) + .limit(limit) + .fetch() + + if (creatorIds.isEmpty()) { + return emptyList() + } + + return getCalculateContentByCreatorRows(startDate, endDate, agentId, creatorIds) + } + + fun getCalculateCommunityByCreatorTotalCount(startDate: LocalDateTime, endDate: LocalDateTime, agentId: Long): Int { + return queryFactory + .select(member.id.countDistinct()) + .from(useCan) + .innerJoin(useCan.communityPost, creatorCommunity) + .innerJoin(creatorCommunity.member, member) + .innerJoin(agentCreatorRelation) + .on( + agentCreatorRelation.creator.id.eq(member.id) + .and(assignedToAgentAtEventTime(agentId, useCan.createdAt)) + ) + .where( + useCan.isRefund.isFalse + .and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST)) + .and(useCan.createdAt.goe(startDate)) + .and(useCan.createdAt.loe(endDate)) + ) + .fetchOne() + ?.toInt() + ?: 0 + } + + fun getCalculateCommunityByCreator( + startDate: LocalDateTime, + endDate: LocalDateTime, + agentId: Long + ): List { + return getCalculateCommunityByCreatorRows(startDate, endDate, agentId, creatorIds = null) + } + + fun getCalculateCommunityByCreator( + startDate: LocalDateTime, + endDate: LocalDateTime, + agentId: Long, + offset: Long, + limit: Long + ): List { + val creatorIds = queryFactory + .select(member.id) + .from(useCan) + .innerJoin(useCan.communityPost, creatorCommunity) + .innerJoin(creatorCommunity.member, member) + .innerJoin(agentCreatorRelation) + .on( + agentCreatorRelation.creator.id.eq(member.id) + .and(assignedToAgentAtEventTime(agentId, useCan.createdAt)) + ) + .where( + useCan.isRefund.isFalse + .and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST)) + .and(useCan.createdAt.goe(startDate)) + .and(useCan.createdAt.loe(endDate)) + ) + .groupBy(member.id) + .orderBy(member.id.desc()) + .offset(offset) + .limit(limit) + .fetch() + + if (creatorIds.isEmpty()) { + return emptyList() + } + + return getCalculateCommunityByCreatorRows(startDate, endDate, agentId, creatorIds) + } + + fun getCalculateContentDonationByCreatorTotalCount( + startDate: LocalDateTime, + endDate: LocalDateTime, + agentId: Long + ): Int { + return queryFactory + .select(member.id.countDistinct()) + .from(useCan) + .innerJoin(useCan.audioContent, audioContent) + .innerJoin(audioContent.member, member) + .innerJoin(agentCreatorRelation) + .on( + agentCreatorRelation.creator.id.eq(member.id) + .and(assignedToAgentAtEventTime(agentId, useCan.createdAt)) + ) + .where( + useCan.isRefund.isFalse + .and(useCan.canUsage.eq(CanUsage.DONATION)) + .and(useCan.createdAt.goe(startDate)) + .and(useCan.createdAt.loe(endDate)) + ) + .fetchOne() + ?.toInt() + ?: 0 + } + + fun getCalculateContentDonationByCreator( + startDate: LocalDateTime, + endDate: LocalDateTime, + agentId: Long + ): List { + return getCalculateContentDonationByCreatorRows(startDate, endDate, agentId, creatorIds = null) + } + + fun getCalculateContentDonationByCreator( + startDate: LocalDateTime, + endDate: LocalDateTime, + agentId: Long, + offset: Long, + limit: Long + ): List { + val creatorIds = queryFactory + .select(member.id) + .from(useCan) + .innerJoin(useCan.audioContent, audioContent) + .innerJoin(audioContent.member, member) + .innerJoin(agentCreatorRelation) + .on( + agentCreatorRelation.creator.id.eq(member.id) + .and(assignedToAgentAtEventTime(agentId, useCan.createdAt)) + ) + .where( + useCan.isRefund.isFalse + .and(useCan.canUsage.eq(CanUsage.DONATION)) + .and(useCan.createdAt.goe(startDate)) + .and(useCan.createdAt.loe(endDate)) + ) + .groupBy(member.id) + .orderBy(member.id.desc()) + .offset(offset) + .limit(limit) + .fetch() + + if (creatorIds.isEmpty()) { + return emptyList() + } + + return getCalculateContentDonationByCreatorRows(startDate, endDate, agentId, creatorIds) + } + + fun getChannelDonationByCreatorTotalCount(startDate: LocalDateTime, endDate: LocalDateTime, agentId: Long): Int { + return queryFactory + .select(member.id.countDistinct()) + .from(useCanCalculate) + .innerJoin(useCanCalculate.useCan, useCan) + .innerJoin(member) + .on(member.id.eq(useCanCalculate.recipientCreatorId)) + .innerJoin(agentCreatorRelation) + .on( + agentCreatorRelation.creator.id.eq(member.id) + .and(assignedToAgentAtEventTime(agentId, useCan.createdAt)) + ) + .where( + useCan.canUsage.eq(CanUsage.CHANNEL_DONATION) + .and(useCan.isRefund.isFalse) + .and(useCanCalculate.status.eq(UseCanCalculateStatus.RECEIVED)) + .and(useCan.createdAt.goe(startDate)) + .and(useCan.createdAt.loe(endDate)) + ) + .fetchOne() + ?.toInt() + ?: 0 + } + + fun getChannelDonationByCreator( + startDate: LocalDateTime, + endDate: LocalDateTime, + agentId: Long + ): List { + return getChannelDonationByCreatorRows(startDate, endDate, agentId, creatorIds = null) + } + + fun getChannelDonationByCreator( + startDate: LocalDateTime, + endDate: LocalDateTime, + agentId: Long, + offset: Long, + limit: Long + ): List { + val creatorIds = queryFactory + .select(member.id) + .from(useCanCalculate) + .innerJoin(useCanCalculate.useCan, useCan) + .innerJoin(member) + .on(member.id.eq(useCanCalculate.recipientCreatorId)) + .innerJoin(agentCreatorRelation) + .on( + agentCreatorRelation.creator.id.eq(member.id) + .and(assignedToAgentAtEventTime(agentId, useCan.createdAt)) + ) + .where( + useCan.canUsage.eq(CanUsage.CHANNEL_DONATION) + .and(useCan.isRefund.isFalse) + .and(useCanCalculate.status.eq(UseCanCalculateStatus.RECEIVED)) + .and(useCan.createdAt.goe(startDate)) + .and(useCan.createdAt.loe(endDate)) + ) + .groupBy(member.id) + .orderBy(member.id.desc()) + .offset(offset) + .limit(limit) + .fetch() + + if (creatorIds.isEmpty()) { + return emptyList() + } + + return getChannelDonationByCreatorRows(startDate, endDate, agentId, creatorIds) + } + + private fun getCalculateLiveByCreatorRows( + startDate: LocalDateTime, + endDate: LocalDateTime, + agentId: Long, + creatorIds: List? + ): List { + return queryFactory + .select( + QGetAgentCreatorSettlementSummaryQueryData( + member.id, + member.nickname, + agentCreatorRelation.id, + agentSettlementRatio.id, + useCan.id.count(), + useCan.can.add(useCan.rewardCan).sum(), + creatorSettlementRatio.liveSettlementRatio, + agentSettlementRatio.settlementRatio + ) + ) + .from(useCan) + .innerJoin(useCan.room, liveRoom) + .innerJoin(liveRoom.member, member) + .innerJoin(agentCreatorRelation) + .on( + agentCreatorRelation.creator.id.eq(member.id) + .and(assignedToAgentAtEventTime(agentId, useCan.createdAt)) + ) + .leftJoin(creatorSettlementRatio) + .on( + member.id.eq(creatorSettlementRatio.member.id) + .and(creatorSettlementRatio.deletedAt.isNull) + ) + .leftJoin(agentSettlementRatio) + .on(appliedAgentSettlementRatioAtEventTime(agentId, useCan.createdAt)) + .where( + useCan.isRefund.isFalse + .and(useCan.createdAt.goe(startDate)) + .and(useCan.createdAt.loe(endDate)) + .and(if (!creatorIds.isNullOrEmpty()) member.id.`in`(creatorIds) else null) + ) + .groupBy( + member.id, + member.nickname, + agentCreatorRelation.id, + agentSettlementRatio.id, + creatorSettlementRatio.liveSettlementRatio, + agentSettlementRatio.settlementRatio + ) + .orderBy(member.id.desc()) + .fetch() + } + + private fun getCalculateContentByCreatorRows( + startDate: LocalDateTime, + endDate: LocalDateTime, + agentId: Long, + creatorIds: List? + ): List { + val contentSettlementRatio = audioContent.settlementRatio.coalesce(creatorSettlementRatio.contentSettlementRatio) + + return queryFactory + .select( + QGetAgentCreatorSettlementSummaryQueryData( + member.id, + member.nickname, + agentCreatorRelation.id, + agentSettlementRatio.id, + order.id.count(), + order.can.sum(), + contentSettlementRatio, + agentSettlementRatio.settlementRatio + ) + ) + .from(order) + .innerJoin(order.audioContent, audioContent) + .innerJoin(audioContent.member, member) + .innerJoin(agentCreatorRelation) + .on( + agentCreatorRelation.creator.id.eq(member.id) + .and(assignedToAgentAtEventTime(agentId, order.createdAt)) + ) + .leftJoin(creatorSettlementRatio) + .on( + member.id.eq(creatorSettlementRatio.member.id) + .and(creatorSettlementRatio.deletedAt.isNull) + ) + .leftJoin(agentSettlementRatio) + .on(appliedAgentSettlementRatioAtEventTime(agentId, order.createdAt)) + .where( + order.createdAt.goe(startDate) + .and(order.createdAt.loe(endDate)) + .and(order.isActive.isTrue) + .and(if (creatorIds != null && creatorIds.isNotEmpty()) member.id.`in`(creatorIds) else null) + ) + .groupBy( + member.id, + member.nickname, + agentCreatorRelation.id, + agentSettlementRatio.id, + contentSettlementRatio, + agentSettlementRatio.settlementRatio + ) + .orderBy(member.id.desc()) + .fetch() + } + + private fun getCalculateCommunityByCreatorRows( + startDate: LocalDateTime, + endDate: LocalDateTime, + agentId: Long, + creatorIds: List? + ): List { + return queryFactory + .select( + QGetAgentCreatorSettlementSummaryQueryData( + member.id, + member.nickname, + agentCreatorRelation.id, + agentSettlementRatio.id, + useCan.id.count(), + useCan.can.add(useCan.rewardCan).sum(), + creatorSettlementRatio.communitySettlementRatio, + agentSettlementRatio.settlementRatio + ) + ) + .from(useCan) + .innerJoin(useCan.communityPost, creatorCommunity) + .innerJoin(creatorCommunity.member, member) + .innerJoin(agentCreatorRelation) + .on( + agentCreatorRelation.creator.id.eq(member.id) + .and(assignedToAgentAtEventTime(agentId, useCan.createdAt)) + ) + .leftJoin(creatorSettlementRatio) + .on( + member.id.eq(creatorSettlementRatio.member.id) + .and(creatorSettlementRatio.deletedAt.isNull) + ) + .leftJoin(agentSettlementRatio) + .on(appliedAgentSettlementRatioAtEventTime(agentId, useCan.createdAt)) + .where( + useCan.isRefund.isFalse + .and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST)) + .and(useCan.createdAt.goe(startDate)) + .and(useCan.createdAt.loe(endDate)) + .and(if (creatorIds != null && creatorIds.isNotEmpty()) member.id.`in`(creatorIds) else null) + ) + .groupBy( + member.id, + member.nickname, + agentCreatorRelation.id, + agentSettlementRatio.id, + creatorSettlementRatio.communitySettlementRatio, + agentSettlementRatio.settlementRatio + ) + .orderBy(member.id.desc()) + .fetch() + } + + private fun getCalculateContentDonationByCreatorRows( + startDate: LocalDateTime, + endDate: LocalDateTime, + agentId: Long, + creatorIds: List? + ): List { + return queryFactory + .select( + QGetAgentCreatorSettlementSummaryQueryData( + member.id, + member.nickname, + agentCreatorRelation.id, + agentSettlementRatio.id, + useCan.id.count(), + useCan.can.add(useCan.rewardCan).sum(), + Expressions.numberTemplate(Int::class.java, "70"), + agentSettlementRatio.settlementRatio + ) + ) + .from(useCan) + .innerJoin(useCan.audioContent, audioContent) + .innerJoin(audioContent.member, member) + .innerJoin(agentCreatorRelation) + .on( + agentCreatorRelation.creator.id.eq(member.id) + .and(assignedToAgentAtEventTime(agentId, useCan.createdAt)) + ) + .leftJoin(agentSettlementRatio) + .on(appliedAgentSettlementRatioAtEventTime(agentId, useCan.createdAt)) + .where( + useCan.isRefund.isFalse + .and(useCan.canUsage.eq(CanUsage.DONATION)) + .and(useCan.createdAt.goe(startDate)) + .and(useCan.createdAt.loe(endDate)) + .and(if (creatorIds != null && creatorIds.isNotEmpty()) member.id.`in`(creatorIds) else null) + ) + .groupBy( + member.id, + member.nickname, + agentCreatorRelation.id, + agentSettlementRatio.id, + agentSettlementRatio.settlementRatio + ) + .orderBy(member.id.desc()) + .fetch() + } + + private fun getChannelDonationByCreatorRows( + startDate: LocalDateTime, + endDate: LocalDateTime, + agentId: Long, + creatorIds: List? + ): List { + return queryFactory + .select( + QGetAgentChannelDonationSettlementByCreatorQueryData( + member.id, + member.nickname, + agentCreatorRelation.id, + agentSettlementRatio.id, + useCan.id.countDistinct(), + useCanCalculate.can.sum(), + agentSettlementRatio.settlementRatio + ) + ) + .from(useCanCalculate) + .innerJoin(useCanCalculate.useCan, useCan) + .innerJoin(member) + .on(member.id.eq(useCanCalculate.recipientCreatorId)) + .innerJoin(agentCreatorRelation) + .on( + agentCreatorRelation.creator.id.eq(member.id) + .and(assignedToAgentAtEventTime(agentId, useCan.createdAt)) + ) + .leftJoin(agentSettlementRatio) + .on(appliedAgentSettlementRatioAtEventTime(agentId, useCan.createdAt)) + .where( + useCan.canUsage.eq(CanUsage.CHANNEL_DONATION) + .and(useCan.isRefund.isFalse) + .and(useCanCalculate.status.eq(UseCanCalculateStatus.RECEIVED)) + .and(useCan.createdAt.goe(startDate)) + .and(useCan.createdAt.loe(endDate)) + .and(if (creatorIds != null && creatorIds.isNotEmpty()) member.id.`in`(creatorIds) else null) + ) + .groupBy( + member.id, + member.nickname, + agentCreatorRelation.id, + agentSettlementRatio.id, + agentSettlementRatio.settlementRatio + ) + .orderBy(member.id.desc()) + .fetch() + } + + private fun assignedToAgentAtEventTime( + agentId: Long, + eventTime: DateTimeExpression + ) = agentCreatorRelation.agent.id.eq(agentId) + .and(agentCreatorRelation.assignedAt.loe(eventTime)) + .and(agentCreatorRelation.unassignedAt.isNull.or(agentCreatorRelation.unassignedAt.gt(eventTime))) + + private fun assignedToAgentAtCurrentTime( + agentId: Long, + currentTime: LocalDateTime + ) = agentCreatorRelation.agent.id.eq(agentId) + .and(agentCreatorRelation.assignedAt.loe(currentTime)) + .and(agentCreatorRelation.unassignedAt.isNull.or(agentCreatorRelation.unassignedAt.gt(currentTime))) + + private fun appliedAgentSettlementRatioAtEventTime( + agentId: Long, + eventTime: DateTimeExpression + ) = agentSettlementRatio.member.id.eq(agentId) + .and(agentSettlementRatio.effectiveFrom.loe(eventTime)) + .and(agentSettlementRatio.effectiveTo.isNull.or(agentSettlementRatio.effectiveTo.gt(eventTime))) +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateService.kt new file mode 100644 index 00000000..9e0712f4 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateService.kt @@ -0,0 +1,219 @@ +package kr.co.vividnext.sodalive.partner.agent.calculate + +import kr.co.vividnext.sodalive.extensions.convertLocalDateTime +import kr.co.vividnext.sodalive.partner.agent.settlement.snapshot.AgentSettlementSnapshotRepository +import kr.co.vividnext.sodalive.partner.agent.settlement.snapshot.AgentSettlementSnapshotType +import kr.co.vividnext.sodalive.partner.agent.settlement.snapshot.toChannelDonationSettlementByCreatorItems +import kr.co.vividnext.sodalive.partner.agent.settlement.snapshot.toSettlementByCreatorItems +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime + +@Service +class AgentCalculateService( + private val repository: AgentCalculateQueryRepository, + private val snapshotRepository: AgentSettlementSnapshotRepository +) { + @Transactional(readOnly = true) + fun getAssignedCreators(agentId: Long, offset: Long, limit: Long): GetAgentAssignedCreatorResponse { + val currentTime = LocalDateTime.now() + val totalCount = repository.getAssignedCreatorTotalCount(agentId = agentId, currentTime = currentTime) + val items = repository.getAssignedCreators(agentId = agentId, offset = offset, limit = limit, currentTime = currentTime) + return GetAgentAssignedCreatorResponse(totalCount = totalCount, items = items) + } + + @Transactional(readOnly = true) + fun getCalculateLiveByCreator( + startDateStr: String, + endDateStr: String, + agentId: Long, + offset: Long, + limit: Long + ): GetAgentSettlementByCreatorResponse { + return buildSettlementByCreatorResponse( + startDateStr = startDateStr, + endDateStr = endDateStr, + settlementType = AgentSettlementSnapshotType.LIVE, + agentId = agentId, + offset = offset, + limit = limit, + totalCountLoader = { startDate, endDate -> + repository.getCalculateLiveByCreatorTotalCount(startDate, endDate, agentId) + }, + totalRowsLoader = { startDate, endDate -> + repository.getCalculateLiveByCreator(startDate, endDate, agentId) + }, + pagedRowsLoader = { startDate, endDate -> + repository.getCalculateLiveByCreator(startDate, endDate, agentId, offset, limit) + } + ) + } + + @Transactional(readOnly = true) + fun getCalculateContentByCreator( + startDateStr: String, + endDateStr: String, + agentId: Long, + offset: Long, + limit: Long + ): GetAgentSettlementByCreatorResponse { + return buildSettlementByCreatorResponse( + startDateStr = startDateStr, + endDateStr = endDateStr, + settlementType = AgentSettlementSnapshotType.CONTENT, + agentId = agentId, + offset = offset, + limit = limit, + totalCountLoader = { startDate, endDate -> + repository.getCalculateContentByCreatorTotalCount(startDate, endDate, agentId) + }, + totalRowsLoader = { startDate, endDate -> + repository.getCalculateContentByCreator(startDate, endDate, agentId) + }, + pagedRowsLoader = { startDate, endDate -> + repository.getCalculateContentByCreator(startDate, endDate, agentId, offset, limit) + } + ) + } + + @Transactional(readOnly = true) + fun getCalculateCommunityByCreator( + startDateStr: String, + endDateStr: String, + agentId: Long, + offset: Long, + limit: Long + ): GetAgentSettlementByCreatorResponse { + return buildSettlementByCreatorResponse( + startDateStr = startDateStr, + endDateStr = endDateStr, + settlementType = AgentSettlementSnapshotType.COMMUNITY, + agentId = agentId, + offset = offset, + limit = limit, + totalCountLoader = { startDate, endDate -> + repository.getCalculateCommunityByCreatorTotalCount(startDate, endDate, agentId) + }, + totalRowsLoader = { startDate, endDate -> + repository.getCalculateCommunityByCreator(startDate, endDate, agentId) + }, + pagedRowsLoader = { startDate, endDate -> + repository.getCalculateCommunityByCreator(startDate, endDate, agentId, offset, limit) + } + ) + } + + @Transactional(readOnly = true) + fun getCalculateContentDonationByCreator( + startDateStr: String, + endDateStr: String, + agentId: Long, + offset: Long, + limit: Long + ): GetAgentSettlementByCreatorResponse { + return buildSettlementByCreatorResponse( + startDateStr = startDateStr, + endDateStr = endDateStr, + settlementType = AgentSettlementSnapshotType.CONTENT_DONATION, + agentId = agentId, + offset = offset, + limit = limit, + totalCountLoader = { startDate, endDate -> + repository.getCalculateContentDonationByCreatorTotalCount(startDate, endDate, agentId) + }, + totalRowsLoader = { startDate, endDate -> + repository.getCalculateContentDonationByCreator(startDate, endDate, agentId) + }, + pagedRowsLoader = { startDate, endDate -> + repository.getCalculateContentDonationByCreator(startDate, endDate, agentId, offset, limit) + } + ) + } + + @Transactional(readOnly = true) + fun getChannelDonationByCreator( + startDateStr: String, + endDateStr: String, + agentId: Long, + offset: Long, + limit: Long + ): GetAgentChannelDonationSettlementByCreatorResponse { + val (startDate, endDate) = toDateRange(startDateStr, endDateStr) + val snapshots = snapshotRepository.findAllByPeriodStartAndPeriodEndAndSettlementTypeAndAgentIdOrderByCreatorIdDesc( + periodStart = startDate, + periodEnd = endDate, + settlementType = AgentSettlementSnapshotType.CHANNEL_DONATION, + agentId = agentId + ) + if (snapshots.isNotEmpty()) { + val total = snapshots.toChannelDonationSettlementByCreatorItems().toResponseTotal() + val items = snapshots.drop(offset.toInt()).take(limit.toInt()).toChannelDonationSettlementByCreatorItems() + return GetAgentChannelDonationSettlementByCreatorResponse( + totalCount = snapshots.size, + total = total, + items = items + ) + } + + val totalCount = repository.getChannelDonationByCreatorTotalCount(startDate, endDate, agentId) + val total = repository.getChannelDonationByCreator(startDate, endDate, agentId) + .toMergedResponseItems() + .toResponseTotal() + val items = repository.getChannelDonationByCreator(startDate, endDate, agentId, offset, limit) + .toMergedResponseItems() + + return GetAgentChannelDonationSettlementByCreatorResponse( + totalCount = totalCount, + total = total, + items = items + ) + } + + private fun buildSettlementByCreatorResponse( + startDateStr: String, + endDateStr: String, + settlementType: AgentSettlementSnapshotType, + agentId: Long, + offset: Long, + limit: Long, + totalCountLoader: (LocalDateTime, LocalDateTime) -> Int, + totalRowsLoader: (LocalDateTime, LocalDateTime) -> List, + pagedRowsLoader: (LocalDateTime, LocalDateTime) -> List + ): GetAgentSettlementByCreatorResponse { + val (startDate, endDate) = toDateRange(startDateStr, endDateStr) + val snapshots = snapshotRepository.findAllByPeriodStartAndPeriodEndAndSettlementTypeAndAgentIdOrderByCreatorIdDesc( + periodStart = startDate, + periodEnd = endDate, + settlementType = settlementType, + agentId = agentId + ) + if (snapshots.isNotEmpty()) { + val total = snapshots.toSettlementByCreatorItems().toResponseTotal() + val items = snapshots.drop(offset.toInt()).take(limit.toInt()).toSettlementByCreatorItems() + return GetAgentSettlementByCreatorResponse( + totalCount = snapshots.size, + total = total, + items = items + ) + } + + val totalCount = totalCountLoader(startDate, endDate) + val total = totalRowsLoader(startDate, endDate) + .toMergedResponseItems() + .toResponseTotal() + val items = pagedRowsLoader(startDate, endDate) + .toMergedResponseItems() + + return GetAgentSettlementByCreatorResponse( + totalCount = totalCount, + total = total, + items = items + ) + } + + 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 + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/GetAgentAssignedCreatorResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/GetAgentAssignedCreatorResponse.kt new file mode 100644 index 00000000..7d76e099 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/GetAgentAssignedCreatorResponse.kt @@ -0,0 +1,13 @@ +package kr.co.vividnext.sodalive.partner.agent.calculate + +import com.querydsl.core.annotations.QueryProjection + +data class GetAgentAssignedCreatorResponse( + val totalCount: Int, + val items: List +) + +data class GetAgentAssignedCreatorItem @QueryProjection constructor( + val creatorId: Long, + val creatorNickname: String +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/GetAgentChannelDonationSettlementByCreatorResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/GetAgentChannelDonationSettlementByCreatorResponse.kt new file mode 100644 index 00000000..53fde8e5 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/GetAgentChannelDonationSettlementByCreatorResponse.kt @@ -0,0 +1,102 @@ +package kr.co.vividnext.sodalive.partner.agent.calculate + +import com.querydsl.core.annotations.QueryProjection +import kr.co.vividnext.sodalive.calculate.channelDonation.ChannelDonationSettlementCalculator +import java.math.BigDecimal +import java.math.RoundingMode + +data class GetAgentChannelDonationSettlementByCreatorResponse( + val totalCount: Int, + val total: GetAgentChannelDonationSettlementTotal, + val items: List +) + +data class GetAgentChannelDonationSettlementTotal( + val count: Int, + val totalCan: Int, + val krw: Int, + val fee: Int, + val settlementAmount: Int, + val withholdingTax: Int, + val depositAmount: Int, + val agentSettlementAmount: Int +) + +data class GetAgentChannelDonationSettlementByCreatorItem( + val creatorId: Long, + val creatorNickname: String, + val count: Int, + val totalCan: Int, + val krw: Int, + val fee: Int, + val settlementAmount: Int, + val withholdingTax: Int, + val depositAmount: Int, + val agentSettlementAmount: Int +) + +data class GetAgentChannelDonationSettlementByCreatorQueryData @QueryProjection constructor( + val creatorId: Long, + val creatorNickname: String, + val assignmentId: Long? = null, + val agentSettlementRatioId: Long? = null, + val count: Long, + val totalCan: Int, + val agentSettlementRatio: Int? = null +) { + fun toResponseItem(): GetAgentChannelDonationSettlementByCreatorItem { + val amount = ChannelDonationSettlementCalculator.calculate(totalCan) + return GetAgentChannelDonationSettlementByCreatorItem( + creatorId = creatorId, + creatorNickname = creatorNickname, + count = count.toInt(), + totalCan = totalCan, + krw = amount.krw, + fee = amount.fee, + settlementAmount = amount.settlementAmount, + withholdingTax = amount.withholdingTax, + depositAmount = amount.depositAmount, + agentSettlementAmount = BigDecimal(amount.settlementAmount) + .multiply(BigDecimal(agentSettlementRatio ?: DEFAULT_AGENT_SETTLEMENT_RATIO).divide(BigDecimal("100"))) + .setScale(0, RoundingMode.HALF_UP) + .toInt() + ) + } + + companion object { + private const val DEFAULT_AGENT_SETTLEMENT_RATIO = 10 + } +} + +fun List.toMergedResponseItems(): + List { + return map { it.toResponseItem() } + .groupBy { it.creatorId } + .map { (_, items) -> + items.reduce { acc, item -> + acc.copy( + count = acc.count + item.count, + totalCan = acc.totalCan + item.totalCan, + krw = acc.krw + item.krw, + fee = acc.fee + item.fee, + settlementAmount = acc.settlementAmount + item.settlementAmount, + withholdingTax = acc.withholdingTax + item.withholdingTax, + depositAmount = acc.depositAmount + item.depositAmount, + agentSettlementAmount = acc.agentSettlementAmount + item.agentSettlementAmount + ) + } + } +} + +fun List.toResponseTotal(): GetAgentChannelDonationSettlementTotal { + return GetAgentChannelDonationSettlementTotal( + count = sumOf { it.count }, + totalCan = sumOf { it.totalCan }, + krw = sumOf { it.krw }, + fee = sumOf { it.fee }, + settlementAmount = sumOf { it.settlementAmount }, + withholdingTax = sumOf { it.withholdingTax }, + depositAmount = sumOf { it.depositAmount }, + agentSettlementAmount = sumOf { it.agentSettlementAmount } + ) +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/GetAgentCreatorSettlementSummaryQueryData.kt b/src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/GetAgentCreatorSettlementSummaryQueryData.kt new file mode 100644 index 00000000..13b32e65 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/GetAgentCreatorSettlementSummaryQueryData.kt @@ -0,0 +1,77 @@ +package kr.co.vividnext.sodalive.partner.agent.calculate + +import com.querydsl.core.annotations.QueryProjection +import java.math.BigDecimal +import java.math.RoundingMode + +data class GetAgentCreatorSettlementSummaryQueryData @QueryProjection constructor( + val creatorId: Long, + val creatorNickname: String, + val assignmentId: Long? = null, + val agentSettlementRatioId: Long? = null, + val count: Long, + val totalCan: Int, + val settlementRatio: Int?, + val agentSettlementRatio: Int? = null +) { + fun toResponseItem(): GetAgentSettlementByCreatorItem { + val totalKrw = BigDecimal(totalCan).multiply(KRW_PER_CAN) + val fee = totalKrw.multiply(PAYMENT_FEE_RATE) + val settlementAmount = totalKrw.subtract(fee) + .multiply(BigDecimal(settlementRatio ?: DEFAULT_SETTLEMENT_RATIO).divide(PERCENT_DIVISOR)) + val tax = settlementAmount.multiply(TAX_RATE) + val depositAmount = settlementAmount.subtract(tax) + val roundedSettlementAmount = settlementAmount.setScale(0, RoundingMode.HALF_UP).toInt() + + return GetAgentSettlementByCreatorItem( + creatorId = creatorId, + creatorNickname = creatorNickname, + count = count.toInt(), + totalCan = totalCan, + krw = totalKrw.toInt(), + fee = fee.setScale(0, RoundingMode.HALF_UP).toInt(), + settlementAmount = roundedSettlementAmount, + tax = tax.setScale(0, RoundingMode.HALF_UP).toInt(), + depositAmount = depositAmount.setScale(0, RoundingMode.HALF_UP).toInt(), + agentSettlementAmount = calculateAgentSettlementAmount( + settlementAmount = roundedSettlementAmount, + agentSettlementRatio = agentSettlementRatio ?: DEFAULT_AGENT_SETTLEMENT_RATIO + ) + ) + } + + companion object { + private val KRW_PER_CAN = BigDecimal("100") + private val PAYMENT_FEE_RATE = BigDecimal("0.066") + private val TAX_RATE = BigDecimal("0.033") + private val PERCENT_DIVISOR = BigDecimal("100") + private const val DEFAULT_SETTLEMENT_RATIO = 70 + private const val DEFAULT_AGENT_SETTLEMENT_RATIO = 10 + + private fun calculateAgentSettlementAmount(settlementAmount: Int, agentSettlementRatio: Int): Int { + return BigDecimal(settlementAmount) + .multiply(BigDecimal(agentSettlementRatio).divide(PERCENT_DIVISOR)) + .setScale(0, RoundingMode.HALF_UP) + .toInt() + } + } +} + +fun List.toMergedResponseItems(): List { + return map { it.toResponseItem() } + .groupBy { it.creatorId } + .map { (_, items) -> + items.reduce { acc, item -> + acc.copy( + count = acc.count + item.count, + totalCan = acc.totalCan + item.totalCan, + krw = acc.krw + item.krw, + fee = acc.fee + item.fee, + settlementAmount = acc.settlementAmount + item.settlementAmount, + tax = acc.tax + item.tax, + depositAmount = acc.depositAmount + item.depositAmount, + agentSettlementAmount = acc.agentSettlementAmount + item.agentSettlementAmount + ) + } + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/GetAgentSettlementByCreatorResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/GetAgentSettlementByCreatorResponse.kt new file mode 100644 index 00000000..1fa32c25 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/GetAgentSettlementByCreatorResponse.kt @@ -0,0 +1,44 @@ +package kr.co.vividnext.sodalive.partner.agent.calculate + +data class GetAgentSettlementByCreatorResponse( + val totalCount: Int, + val total: GetAgentSettlementByCreatorTotal, + val items: List +) + +data class GetAgentSettlementByCreatorTotal( + val count: Int, + val totalCan: Int, + val krw: Int, + val fee: Int, + val settlementAmount: Int, + val tax: Int, + val depositAmount: Int, + val agentSettlementAmount: Int +) + +data class GetAgentSettlementByCreatorItem( + val creatorId: Long, + val creatorNickname: String, + val count: Int, + val totalCan: Int, + val krw: Int, + val fee: Int, + val settlementAmount: Int, + val tax: Int, + val depositAmount: Int, + val agentSettlementAmount: Int +) + +fun List.toResponseTotal(): GetAgentSettlementByCreatorTotal { + return GetAgentSettlementByCreatorTotal( + count = sumOf { it.count }, + totalCan = sumOf { it.totalCan }, + krw = sumOf { it.krw }, + fee = sumOf { it.fee }, + settlementAmount = sumOf { it.settlementAmount }, + tax = sumOf { it.tax }, + depositAmount = sumOf { it.depositAmount }, + agentSettlementAmount = sumOf { it.agentSettlementAmount } + ) +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateControllerTest.kt new file mode 100644 index 00000000..3b7a9046 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateControllerTest.kt @@ -0,0 +1,326 @@ +package kr.co.vividnext.sodalive.partner.agent.calculate + +import kr.co.vividnext.sodalive.common.SodaException +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.Assertions.assertThrows +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 + +class AgentCalculateControllerTest { + private lateinit var service: AgentCalculateService + private lateinit var controller: AgentCalculateController + + @BeforeEach + fun setup() { + service = Mockito.mock(AgentCalculateService::class.java) + controller = AgentCalculateController(service) + } + + @Test + @DisplayName("인증 사용자 정보가 없으면 소속 크리에이터 목록 조회는 예외를 던진다") + fun shouldThrowWhenMemberIsNullForAssignedCreators() { + val exception = assertThrows(SodaException::class.java) { + controller.getAssignedCreators( + member = null, + pageable = PageRequest.of(0, 10) + ) + } + + assertEquals("common.error.bad_credentials", exception.messageKey) + } + + @Test + @DisplayName("에이전트 컨트롤러는 소속 크리에이터 목록 조회 파라미터를 서비스로 전달한다") + fun shouldForwardAssignedCreatorsRequestToService() { + val member = createAgentMember(7L) + val response = GetAgentAssignedCreatorResponse( + totalCount = 1, + items = listOf( + GetAgentAssignedCreatorItem( + creatorId = 21L, + creatorNickname = "creator-a" + ) + ) + ) + + Mockito.`when`(service.getAssignedCreators(agentId = 7L, offset = 10L, limit = 5L)).thenReturn(response) + + val apiResponse = controller.getAssignedCreators( + member = member, + pageable = PageRequest.of(2, 5) + ) + + assertEquals(true, apiResponse.success) + assertEquals(1, apiResponse.data!!.totalCount) + assertEquals(21L, apiResponse.data!!.items[0].creatorId) + Mockito.verify(service).getAssignedCreators(agentId = 7L, offset = 10L, limit = 5L) + } + + @Test + @DisplayName("에이전트 컨트롤러는 라이브 요약 조회 파라미터를 서비스로 전달한다") + fun shouldForwardLiveSummaryRequestToService() { + val member = createAgentMember(7L) + val response = GetAgentSettlementByCreatorResponse( + totalCount = 1, + total = GetAgentSettlementByCreatorTotal( + count = 2, + totalCan = 40, + krw = 4000, + fee = 264, + settlementAmount = 2615, + tax = 86, + depositAmount = 2529, + agentSettlementAmount = 262 + ), + items = listOf( + GetAgentSettlementByCreatorItem( + creatorId = 21L, + creatorNickname = "creator-a", + count = 2, + totalCan = 40, + krw = 4000, + fee = 264, + settlementAmount = 2615, + tax = 86, + depositAmount = 2529, + agentSettlementAmount = 262 + ) + ) + ) + + Mockito.`when`( + service.getCalculateLiveByCreator( + startDateStr = "2026-02-20", + endDateStr = "2026-02-21", + agentId = 7L, + offset = 0L, + limit = 20L + ) + ).thenReturn(response) + + val apiResponse = controller.getCalculateLiveByCreator( + startDateStr = "2026-02-20", + endDateStr = "2026-02-21", + member = member, + pageable = PageRequest.of(0, 20) + ) + + assertEquals(true, apiResponse.success) + assertEquals(262, apiResponse.data!!.items[0].agentSettlementAmount) + Mockito.verify(service).getCalculateLiveByCreator( + startDateStr = "2026-02-20", + endDateStr = "2026-02-21", + agentId = 7L, + offset = 0L, + limit = 20L + ) + } + + @Test + @DisplayName("에이전트 컨트롤러는 콘텐츠 요약 조회 파라미터를 서비스로 전달한다") + fun shouldForwardContentSummaryRequestToService() { + val member = createAgentMember(7L) + val response = createGenericSummaryResponse(agentSettlementAmount = 374) + + Mockito.`when`( + service.getCalculateContentByCreator( + startDateStr = "2026-02-20", + endDateStr = "2026-02-21", + agentId = 7L, + offset = 20L, + limit = 20L + ) + ).thenReturn(response) + + val apiResponse = controller.getCalculateContentByCreator( + startDateStr = "2026-02-20", + endDateStr = "2026-02-21", + member = member, + pageable = PageRequest.of(1, 20) + ) + + assertEquals(true, apiResponse.success) + assertEquals(374, apiResponse.data!!.items[0].agentSettlementAmount) + Mockito.verify(service).getCalculateContentByCreator( + startDateStr = "2026-02-20", + endDateStr = "2026-02-21", + agentId = 7L, + offset = 20L, + limit = 20L + ) + } + + @Test + @DisplayName("에이전트 컨트롤러는 커뮤니티 요약 조회 파라미터를 서비스로 전달한다") + fun shouldForwardCommunitySummaryRequestToService() { + val member = createAgentMember(7L) + val response = createGenericSummaryResponse(agentSettlementAmount = 168) + + Mockito.`when`( + service.getCalculateCommunityByCreator( + startDateStr = "2026-02-20", + endDateStr = "2026-02-21", + agentId = 7L, + offset = 0L, + limit = 10L + ) + ).thenReturn(response) + + val apiResponse = controller.getCalculateCommunityByCreator( + startDateStr = "2026-02-20", + endDateStr = "2026-02-21", + member = member, + pageable = PageRequest.of(0, 10) + ) + + assertEquals(true, apiResponse.success) + assertEquals(168, apiResponse.data!!.items[0].agentSettlementAmount) + Mockito.verify(service).getCalculateCommunityByCreator( + startDateStr = "2026-02-20", + endDateStr = "2026-02-21", + agentId = 7L, + offset = 0L, + limit = 10L + ) + } + + @Test + @DisplayName("에이전트 컨트롤러는 채널후원 요약 조회 파라미터를 서비스로 전달한다") + fun shouldForwardChannelDonationSummaryRequestToService() { + val member = createAgentMember(7L) + val response = GetAgentChannelDonationSettlementByCreatorResponse( + totalCount = 1, + total = GetAgentChannelDonationSettlementTotal( + count = 1, + totalCan = 50, + krw = 5000, + fee = 330, + settlementAmount = 3970, + withholdingTax = 131, + depositAmount = 3839, + agentSettlementAmount = 397 + ), + items = listOf( + GetAgentChannelDonationSettlementByCreatorItem( + creatorId = 21L, + creatorNickname = "creator-a", + count = 1, + totalCan = 50, + krw = 5000, + fee = 330, + settlementAmount = 3970, + withholdingTax = 131, + depositAmount = 3839, + agentSettlementAmount = 397 + ) + ) + ) + + Mockito.`when`( + service.getChannelDonationByCreator( + startDateStr = "2026-02-20", + endDateStr = "2026-02-21", + agentId = 7L, + offset = 0L, + limit = 10L + ) + ).thenReturn(response) + + val apiResponse = controller.getChannelDonationByCreator( + startDateStr = "2026-02-20", + endDateStr = "2026-02-21", + member = member, + pageable = PageRequest.of(0, 10) + ) + + assertEquals(true, apiResponse.success) + assertEquals(397, apiResponse.data!!.items[0].agentSettlementAmount) + Mockito.verify(service).getChannelDonationByCreator( + startDateStr = "2026-02-20", + endDateStr = "2026-02-21", + agentId = 7L, + offset = 0L, + limit = 10L + ) + } + + @Test + @DisplayName("에이전트 컨트롤러는 콘텐츠후원 요약 조회 파라미터를 서비스로 전달한다") + fun shouldForwardContentDonationSummaryRequestToService() { + val member = createAgentMember(7L) + val response = createGenericSummaryResponse(agentSettlementAmount = 131) + + Mockito.`when`( + service.getCalculateContentDonationByCreator( + startDateStr = "2026-02-20", + endDateStr = "2026-02-21", + agentId = 7L, + offset = 0L, + limit = 10L + ) + ).thenReturn(response) + + val apiResponse = controller.getCalculateContentDonationByCreator( + startDateStr = "2026-02-20", + endDateStr = "2026-02-21", + member = member, + pageable = PageRequest.of(0, 10) + ) + + assertEquals(true, apiResponse.success) + assertEquals(131, apiResponse.data!!.items[0].agentSettlementAmount) + Mockito.verify(service).getCalculateContentDonationByCreator( + startDateStr = "2026-02-20", + endDateStr = "2026-02-21", + agentId = 7L, + offset = 0L, + limit = 10L + ) + } + + private fun createAgentMember(id: Long): Member { + val member = Member( + email = "agent@test.com", + password = "password", + nickname = "agent", + role = MemberRole.AGENT + ) + member.id = id + return member + } + + private fun createGenericSummaryResponse(agentSettlementAmount: Int): GetAgentSettlementByCreatorResponse { + return GetAgentSettlementByCreatorResponse( + totalCount = 1, + total = GetAgentSettlementByCreatorTotal( + count = 1, + totalCan = 50, + krw = 5000, + fee = 330, + settlementAmount = 3736, + tax = 123, + depositAmount = 3613, + agentSettlementAmount = agentSettlementAmount + ), + items = listOf( + GetAgentSettlementByCreatorItem( + creatorId = 21L, + creatorNickname = "creator-a", + count = 1, + totalCan = 50, + krw = 5000, + fee = 330, + settlementAmount = 3736, + tax = 123, + depositAmount = 3613, + agentSettlementAmount = agentSettlementAmount + ) + ) + ) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateQueryRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateQueryRepositoryTest.kt new file mode 100644 index 00000000..5c9a5911 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateQueryRepositoryTest.kt @@ -0,0 +1,777 @@ +package kr.co.vividnext.sodalive.partner.agent.calculate + +import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.admin.calculate.ratio.CreatorSettlementRatio +import kr.co.vividnext.sodalive.admin.calculate.ratio.CreatorSettlementRatioRepository +import kr.co.vividnext.sodalive.can.payment.PaymentGateway +import kr.co.vividnext.sodalive.can.use.CanUsage +import kr.co.vividnext.sodalive.can.use.UseCan +import kr.co.vividnext.sodalive.can.use.UseCanCalculate +import kr.co.vividnext.sodalive.can.use.UseCanCalculateRepository +import kr.co.vividnext.sodalive.can.use.UseCanCalculateStatus +import kr.co.vividnext.sodalive.can.use.UseCanRepository +import kr.co.vividnext.sodalive.configs.QueryDslConfig +import kr.co.vividnext.sodalive.content.AudioContent +import kr.co.vividnext.sodalive.content.AudioContentRepository +import kr.co.vividnext.sodalive.content.order.Order +import kr.co.vividnext.sodalive.content.order.OrderRepository +import kr.co.vividnext.sodalive.content.order.OrderType +import kr.co.vividnext.sodalive.content.theme.AudioContentTheme +import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity +import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityRepository +import kr.co.vividnext.sodalive.live.room.LiveRoom +import kr.co.vividnext.sodalive.live.room.LiveRoomRepository +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRepository +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.partner.agent.assignment.AgentCreatorRelation +import kr.co.vividnext.sodalive.partner.agent.assignment.AgentCreatorRelationRepository +import kr.co.vividnext.sodalive.partner.agent.ratio.AgentSettlementRatio +import kr.co.vividnext.sodalive.partner.agent.ratio.AgentSettlementRatioRepository +import kr.co.vividnext.sodalive.partner.agent.settlement.snapshot.AgentSettlementSnapshotRepository +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(properties = ["spring.jpa.database-platform=kr.co.vividnext.sodalive.support.H2MySqlFunctionDialect"]) +@Import(QueryDslConfig::class) +class AgentCalculateQueryRepositoryTest @Autowired constructor( + private val queryFactory: JPAQueryFactory, + private val memberRepository: MemberRepository, + private val relationRepository: AgentCreatorRelationRepository, + private val agentSettlementRatioRepository: AgentSettlementRatioRepository, + private val snapshotRepository: AgentSettlementSnapshotRepository, + private val creatorSettlementRatioRepository: CreatorSettlementRatioRepository, + private val audioContentRepository: AudioContentRepository, + private val orderRepository: OrderRepository, + private val liveRoomRepository: LiveRoomRepository, + private val creatorCommunityRepository: CreatorCommunityRepository, + private val useCanRepository: UseCanRepository, + private val useCanCalculateRepository: UseCanCalculateRepository, + private val entityManager: EntityManager +) { + private lateinit var repository: AgentCalculateQueryRepository + private lateinit var service: AgentCalculateService + + @BeforeEach + fun setup() { + registerMysqlDateFunctions() + repository = AgentCalculateQueryRepository(queryFactory) + service = AgentCalculateService(repository, snapshotRepository) + } + + @Test + @DisplayName("소속 크리에이터 목록 조회는 현재 에이전트에 연결된 크리에이터만 반환한다") + fun shouldGetAssignedCreatorsOnlyForCurrentAgent() { + val agent = saveMember("agent-a", MemberRole.AGENT) + val otherAgent = saveMember("agent-b", MemberRole.AGENT) + val creatorA = saveMember("creator-a", MemberRole.CREATOR) + val creatorB = saveMember("creator-b", MemberRole.CREATOR) + val creatorC = saveMember("creator-c", MemberRole.CREATOR) + val currentTime = LocalDateTime.of(2026, 2, 20, 12, 0, 0) + + saveRelation(agent, creatorA) + saveRelation(agent, creatorB) + saveRelation(otherAgent, creatorC) + + val totalCount = repository.getAssignedCreatorTotalCount(agent.id!!, currentTime) + val items = repository.getAssignedCreators(agent.id!!, offset = 0, limit = 10, currentTime = currentTime) + + assertEquals(2, totalCount) + assertEquals(2, items.size) + assertEquals(listOf(creatorB.id, creatorA.id), items.map { it.creatorId }) + } + + @Test + @DisplayName("소속 크리에이터 목록 조회는 현재 시각 활성 구간의 크리에이터만 반환한다") + fun shouldGetAssignedCreatorsOnlyWithinCurrentAssignmentWindow() { + val agent = saveMember("agent-window", MemberRole.AGENT) + val currentCreator = saveMember("creator-current", MemberRole.CREATOR) + val futureCreator = saveMember("creator-future", MemberRole.CREATOR) + val endingFutureCreator = saveMember("creator-ending-future", MemberRole.CREATOR) + val endedCreator = saveMember("creator-ended", MemberRole.CREATOR) + val currentTime = LocalDateTime.now() + + saveRelation(agent, currentCreator, assignedAt = currentTime.minusDays(2), unassignedAt = null) + saveRelation(agent, futureCreator, assignedAt = currentTime.plusDays(1), unassignedAt = null) + saveRelation(agent, endingFutureCreator, assignedAt = currentTime.minusDays(2), unassignedAt = currentTime.plusDays(1)) + saveRelation(agent, endedCreator, assignedAt = currentTime.minusDays(3), unassignedAt = currentTime.minusHours(1)) + + val totalCount = repository.getAssignedCreatorTotalCount(agent.id!!, currentTime) + val items = repository.getAssignedCreators(agent.id!!, offset = 0, limit = 10, currentTime = currentTime) + + assertEquals(2, totalCount) + assertEquals(listOf(endingFutureCreator.id, currentCreator.id), items.map { it.creatorId }) + } + + @Test + @DisplayName("라이브 크리에이터별 조회는 소속된 크리에이터만 집계한다") + fun shouldGetLiveSummaryRowsOnlyForAssignedCreators() { + val agent = saveMember("agent-live", MemberRole.AGENT) + val creator = saveMember("creator-live", MemberRole.CREATOR) + val otherCreator = saveMember("creator-other-live", MemberRole.CREATOR) + val sender = saveMember("sender-live", MemberRole.USER) + + saveRelation(agent, creator) + saveCreatorSettlementRatio(creator, live = 70, content = 70, community = 70) + saveCreatorSettlementRatio(otherCreator, live = 80, content = 80, community = 80) + + val room = saveLiveRoom(creator) + val otherRoom = saveLiveRoom(otherCreator) + saveLiveUseCan(sender, room, 10, LocalDateTime.of(2026, 2, 20, 10, 0, 0)) + saveLiveUseCan(sender, room, 30, LocalDateTime.of(2026, 2, 20, 11, 0, 0)) + saveLiveUseCan(sender, otherRoom, 50, LocalDateTime.of(2026, 2, 20, 12, 0, 0)) + + val startDate = LocalDateTime.of(2026, 2, 20, 0, 0, 0) + val endDate = LocalDateTime.of(2026, 2, 20, 23, 59, 59) + + val totalCount = repository.getCalculateLiveByCreatorTotalCount(startDate, endDate, agent.id!!) + val rows = repository.getCalculateLiveByCreator(startDate, endDate, agent.id!!) + + assertEquals(1, totalCount) + assertEquals(1, rows.size) + assertEquals(creator.id, rows[0].creatorId) + assertEquals(2L, rows[0].count) + assertEquals(40, rows[0].totalCan) + assertEquals(70, rows[0].settlementRatio) + } + + @Test + @DisplayName("콘텐츠 크리에이터별 조회는 콘텐츠 개별 정산 비율과 기본 정산 비율을 모두 반영한다") + fun shouldGetContentSummaryRowsGroupedByEffectiveSettlementRatio() { + val agent = saveMember("agent-content", MemberRole.AGENT) + val creator = saveMember("creator-content", MemberRole.CREATOR) + val otherCreator = saveMember("creator-other-content", MemberRole.CREATOR) + val buyer = saveMember("buyer-content", MemberRole.USER) + + saveRelation(agent, creator) + saveCreatorSettlementRatio(creator, live = 70, content = 60, community = 70) + saveCreatorSettlementRatio(otherCreator, live = 70, content = 90, community = 70) + + val paidContent = saveAudioContent(creator, "content-a", price = 50, settlementRatio = 80) + val fallbackContent = saveAudioContent(creator, "content-b", price = 30, settlementRatio = null) + val otherContent = saveAudioContent(otherCreator, "content-c", price = 90, settlementRatio = null) + + saveOrder(buyer, creator, paidContent, LocalDateTime.of(2026, 2, 20, 9, 0, 0)) + saveOrder(buyer, creator, fallbackContent, LocalDateTime.of(2026, 2, 20, 10, 0, 0)) + saveOrder(buyer, otherCreator, otherContent, LocalDateTime.of(2026, 2, 20, 11, 0, 0)) + + val startDate = LocalDateTime.of(2026, 2, 20, 0, 0, 0) + val endDate = LocalDateTime.of(2026, 2, 20, 23, 59, 59) + + val totalCount = repository.getCalculateContentByCreatorTotalCount(startDate, endDate, agent.id!!) + val rows = repository.getCalculateContentByCreator(startDate, endDate, agent.id!!) + + assertEquals(1, totalCount) + assertEquals(2, rows.size) + assertEquals(listOf(80, 60), rows.mapNotNull { it.settlementRatio }.sortedDescending()) + assertEquals(listOf(50, 30), rows.map { it.totalCan }.sortedDescending()) + assertEquals(listOf(creator.id!!, creator.id!!), rows.map { it.creatorId }) + } + + @Test + @DisplayName("커뮤니티 크리에이터별 조회는 소속된 크리에이터만 집계한다") + fun shouldGetCommunitySummaryRowsOnlyForAssignedCreators() { + val agent = saveMember("agent-community", MemberRole.AGENT) + val creator = saveMember("creator-community", MemberRole.CREATOR) + val otherCreator = saveMember("creator-other-community", MemberRole.CREATOR) + val buyer = saveMember("buyer-community", MemberRole.USER) + + saveRelation(agent, creator) + saveCreatorSettlementRatio(creator, live = 70, content = 70, community = 60) + saveCreatorSettlementRatio(otherCreator, live = 70, content = 70, community = 80) + + val communityPost = saveCommunityPost(creator, 10) + val otherPost = saveCommunityPost(otherCreator, 20) + + saveCommunityUseCan(buyer, communityPost, 20, LocalDateTime.of(2026, 2, 20, 9, 0, 0)) + saveCommunityUseCan(buyer, communityPost, 10, LocalDateTime.of(2026, 2, 20, 10, 0, 0)) + saveCommunityUseCan(buyer, otherPost, 50, LocalDateTime.of(2026, 2, 20, 11, 0, 0)) + + val startDate = LocalDateTime.of(2026, 2, 20, 0, 0, 0) + val endDate = LocalDateTime.of(2026, 2, 20, 23, 59, 59) + + val totalCount = repository.getCalculateCommunityByCreatorTotalCount(startDate, endDate, agent.id!!) + val rows = repository.getCalculateCommunityByCreator(startDate, endDate, agent.id!!) + + assertEquals(1, totalCount) + assertEquals(1, rows.size) + assertEquals(creator.id, rows[0].creatorId) + assertEquals(2L, rows[0].count) + assertEquals(30, rows[0].totalCan) + assertEquals(60, rows[0].settlementRatio) + } + + @Test + @DisplayName("콘텐츠후원 크리에이터별 조회는 소속된 크리에이터만 집계한다") + fun shouldGetContentDonationSummaryRowsOnlyForAssignedCreators() { + val agent = saveMember("agent-content-donation", MemberRole.AGENT) + val creator = saveMember("creator-content-donation", MemberRole.CREATOR) + val otherCreator = saveMember("creator-other-content-donation", MemberRole.CREATOR) + val buyer = saveMember("buyer-content-donation", MemberRole.USER) + + saveRelation(agent, creator) + + val content = saveAudioContent(creator, "content-donation-a", price = 0, settlementRatio = null) + val otherContent = saveAudioContent(otherCreator, "content-donation-b", price = 0, settlementRatio = null) + + saveContentDonationUseCan(buyer, content, 7, LocalDateTime.of(2026, 2, 20, 9, 0, 0)) + saveContentDonationUseCan(buyer, content, 13, LocalDateTime.of(2026, 2, 20, 10, 0, 0)) + saveContentDonationUseCan(buyer, otherContent, 30, LocalDateTime.of(2026, 2, 20, 11, 0, 0)) + + val startDate = LocalDateTime.of(2026, 2, 20, 0, 0, 0) + val endDate = LocalDateTime.of(2026, 2, 20, 23, 59, 59) + + val totalCount = repository.getCalculateContentDonationByCreatorTotalCount(startDate, endDate, agent.id!!) + val rows = repository.getCalculateContentDonationByCreator(startDate, endDate, agent.id!!) + + assertEquals(1, totalCount) + assertEquals(1, rows.size) + assertEquals(creator.id, rows[0].creatorId) + assertEquals(2L, rows[0].count) + assertEquals(20, rows[0].totalCan) + assertEquals(70, rows[0].settlementRatio) + } + + @Test + @DisplayName("채널후원 크리에이터별 조회는 분할 정산 레코드가 있어도 후원 단위로 집계한다") + fun shouldCountDistinctUseCanForChannelDonationByCreator() { + val agent = saveMember("agent-channel", MemberRole.AGENT) + val creator = saveMember("creator-channel", MemberRole.CREATOR) + val otherCreator = saveMember("creator-other-channel", MemberRole.CREATOR) + val sender = saveMember("sender-channel", MemberRole.USER) + + saveRelation(agent, creator) + + val useCan = saveChannelDonationUseCan(sender, 50, LocalDateTime.of(2026, 2, 20, 9, 0, 0)) + saveUseCanCalculate(useCan, creator.id!!, 20, PaymentGateway.PG) + saveUseCanCalculate(useCan, creator.id!!, 30, PaymentGateway.GOOGLE_IAP) + + val otherUseCan = saveChannelDonationUseCan(sender, 40, LocalDateTime.of(2026, 2, 20, 10, 0, 0)) + saveUseCanCalculate(otherUseCan, otherCreator.id!!, 40, PaymentGateway.PG) + + val startDate = LocalDateTime.of(2026, 2, 20, 0, 0, 0) + val endDate = LocalDateTime.of(2026, 2, 20, 23, 59, 59) + + val totalCount = repository.getChannelDonationByCreatorTotalCount(startDate, endDate, agent.id!!) + val rows = repository.getChannelDonationByCreator(startDate, endDate, agent.id!!) + + assertEquals(1, totalCount) + assertEquals(1, rows.size) + assertEquals(creator.id, rows[0].creatorId) + assertEquals(1L, rows[0].count) + assertEquals(50, rows[0].totalCan) + } + + @Test + @DisplayName("페이지 대상 creator가 없으면 모든 카테고리 조회는 빈 rows를 반환한다") + fun shouldReturnEmptyRowsWhenPagedCreatorSelectionIsEmptyAcrossAllCategories() { + val agent = saveMember("agent-empty-page", MemberRole.AGENT) + val creator = saveMember("creator-empty-page", MemberRole.CREATOR) + val buyer = saveMember("buyer-empty-page", MemberRole.USER) + + saveRelation(agent, creator) + saveAgentSettlementRatio(agent, settlementRatio = 10, effectiveFrom = LocalDateTime.of(2026, 2, 1, 0, 0, 0)) + saveCreatorSettlementRatio(creator, live = 70, content = 70, community = 70) + + val room = saveLiveRoom(creator) + saveLiveUseCan(buyer, room, 10, LocalDateTime.of(2026, 2, 20, 9, 0, 0)) + + val content = saveAudioContent(creator, "empty-page-content", price = 10, settlementRatio = null) + saveOrder(buyer, creator, content, LocalDateTime.of(2026, 2, 20, 10, 0, 0)) + + val communityPost = saveCommunityPost(creator, 10) + saveCommunityUseCan(buyer, communityPost, 10, LocalDateTime.of(2026, 2, 20, 11, 0, 0)) + + saveContentDonationUseCan(buyer, content, 10, LocalDateTime.of(2026, 2, 20, 12, 0, 0)) + + val channelDonation = saveChannelDonationUseCan(buyer, 10, LocalDateTime.of(2026, 2, 20, 13, 0, 0)) + saveUseCanCalculate(channelDonation, creator.id!!, 10, PaymentGateway.PG) + + val startDate = LocalDateTime.of(2026, 2, 20, 0, 0, 0) + val endDate = LocalDateTime.of(2026, 2, 20, 23, 59, 59) + + assertEquals(1, repository.getCalculateLiveByCreatorTotalCount(startDate, endDate, agent.id!!)) + assertEquals(1, repository.getCalculateContentByCreatorTotalCount(startDate, endDate, agent.id!!)) + assertEquals(1, repository.getCalculateCommunityByCreatorTotalCount(startDate, endDate, agent.id!!)) + assertEquals(1, repository.getCalculateContentDonationByCreatorTotalCount(startDate, endDate, agent.id!!)) + assertEquals(1, repository.getChannelDonationByCreatorTotalCount(startDate, endDate, agent.id!!)) + + assertEquals( + 0, + repository.getCalculateLiveByCreator(startDate, endDate, agent.id!!, offset = 1, limit = 10).size + ) + assertEquals( + 0, + repository.getCalculateContentByCreator(startDate, endDate, agent.id!!, offset = 1, limit = 10).size + ) + assertEquals( + 0, + repository.getCalculateCommunityByCreator(startDate, endDate, agent.id!!, offset = 1, limit = 10).size + ) + assertEquals( + 0, + repository.getCalculateContentDonationByCreator(startDate, endDate, agent.id!!, offset = 1, limit = 10).size + ) + assertEquals( + 0, + repository.getChannelDonationByCreator(startDate, endDate, agent.id!!, offset = 1, limit = 10).size + ) + } + + @Test + @DisplayName("정산 조회는 기간 중 소속 변경이 있어도 거래 시점 기준 소속 에이전트에게만 반영한다") + fun shouldResolveAssignmentAtEventTimeAcrossAllSettlementCategories() { + val firstAgent = saveMember("agent-assignment-a", MemberRole.AGENT) + val secondAgent = saveMember("agent-assignment-b", MemberRole.AGENT) + val creator = saveMember("creator-assignment", MemberRole.CREATOR) + val buyer = saveMember("buyer-assignment", MemberRole.USER) + + saveAgentSettlementRatio(firstAgent, settlementRatio = 10, effectiveFrom = LocalDateTime.of(2026, 2, 1, 0, 0, 0)) + saveAgentSettlementRatio(secondAgent, settlementRatio = 10, effectiveFrom = LocalDateTime.of(2026, 2, 1, 0, 0, 0)) + saveRelation( + agent = firstAgent, + creator = creator, + assignedAt = LocalDateTime.of(2026, 2, 1, 0, 0, 0), + unassignedAt = LocalDateTime.of(2026, 2, 20, 12, 0, 0) + ) + saveRelation( + agent = secondAgent, + creator = creator, + assignedAt = LocalDateTime.of(2026, 2, 20, 12, 0, 0), + unassignedAt = null + ) + saveCreatorSettlementRatio(creator, live = 70, content = 70, community = 70) + + val room = saveLiveRoom(creator) + saveLiveUseCan(buyer, room, 10, LocalDateTime.of(2026, 2, 20, 10, 0, 0)) + saveLiveUseCan(buyer, room, 20, LocalDateTime.of(2026, 2, 20, 14, 0, 0)) + + val contentBefore = saveAudioContent(creator, "assignment-content-before", price = 10, settlementRatio = null) + val contentAfter = saveAudioContent(creator, "assignment-content-after", price = 20, settlementRatio = null) + saveOrder(buyer, creator, contentBefore, LocalDateTime.of(2026, 2, 20, 10, 0, 0)) + saveOrder(buyer, creator, contentAfter, LocalDateTime.of(2026, 2, 20, 14, 0, 0)) + + val communityBefore = saveCommunityPost(creator, 10) + val communityAfter = saveCommunityPost(creator, 20) + saveCommunityUseCan(buyer, communityBefore, 10, LocalDateTime.of(2026, 2, 20, 10, 0, 0)) + saveCommunityUseCan(buyer, communityAfter, 20, LocalDateTime.of(2026, 2, 20, 14, 0, 0)) + + saveContentDonationUseCan(buyer, contentBefore, 10, LocalDateTime.of(2026, 2, 20, 10, 0, 0)) + saveContentDonationUseCan(buyer, contentAfter, 20, LocalDateTime.of(2026, 2, 20, 14, 0, 0)) + + val channelBefore = saveChannelDonationUseCan(buyer, 10, LocalDateTime.of(2026, 2, 20, 10, 0, 0)) + saveUseCanCalculate(channelBefore, creator.id!!, 10, PaymentGateway.PG) + val channelAfter = saveChannelDonationUseCan(buyer, 20, LocalDateTime.of(2026, 2, 20, 14, 0, 0)) + saveUseCanCalculate(channelAfter, creator.id!!, 20, PaymentGateway.PG) + + val savedRelations = relationRepository.findAllByCreatorIdOrderByAssignedAtAsc(creator.id!!) + val startDate = LocalDateTime.of(2026, 2, 20, 0, 0, 0) + val endDate = LocalDateTime.of(2026, 2, 20, 23, 59, 59) + val firstAgentLiveRows = repository.getCalculateLiveByCreator(startDate, endDate, firstAgent.id!!) + val secondAgentLiveRows = repository.getCalculateLiveByCreator(startDate, endDate, secondAgent.id!!) + + assertEquals(listOf(savedRelations[0].id), firstAgentLiveRows.map { it.assignmentId }) + assertEquals(listOf(savedRelations[1].id), secondAgentLiveRows.map { it.assignmentId }) + + val firstAgentLive = service.getCalculateLiveByCreator("2026-02-20", "2026-02-20", firstAgent.id!!, 0, 10) + val secondAgentLive = service.getCalculateLiveByCreator("2026-02-20", "2026-02-20", secondAgent.id!!, 0, 10) + assertGenericSettlementResponse(firstAgentLive, expectedCount = 1, expectedTotalCan = 10) + assertGenericSettlementResponse(secondAgentLive, expectedCount = 1, expectedTotalCan = 20) + + val firstAgentContent = service.getCalculateContentByCreator("2026-02-20", "2026-02-20", firstAgent.id!!, 0, 10) + val secondAgentContent = service.getCalculateContentByCreator("2026-02-20", "2026-02-20", secondAgent.id!!, 0, 10) + assertGenericSettlementResponse(firstAgentContent, expectedCount = 1, expectedTotalCan = 10) + assertGenericSettlementResponse(secondAgentContent, expectedCount = 1, expectedTotalCan = 20) + + val firstAgentCommunity = service.getCalculateCommunityByCreator("2026-02-20", "2026-02-20", firstAgent.id!!, 0, 10) + val secondAgentCommunity = service.getCalculateCommunityByCreator("2026-02-20", "2026-02-20", secondAgent.id!!, 0, 10) + assertGenericSettlementResponse(firstAgentCommunity, expectedCount = 1, expectedTotalCan = 10) + assertGenericSettlementResponse(secondAgentCommunity, expectedCount = 1, expectedTotalCan = 20) + + val firstAgentContentDonation = service.getCalculateContentDonationByCreator( + "2026-02-20", + "2026-02-20", + firstAgent.id!!, + 0, + 10 + ) + val secondAgentContentDonation = service.getCalculateContentDonationByCreator( + "2026-02-20", + "2026-02-20", + secondAgent.id!!, + 0, + 10 + ) + assertGenericSettlementResponse(firstAgentContentDonation, expectedCount = 1, expectedTotalCan = 10) + assertGenericSettlementResponse(secondAgentContentDonation, expectedCount = 1, expectedTotalCan = 20) + + val firstAgentChannelDonation = service.getChannelDonationByCreator("2026-02-20", "2026-02-20", firstAgent.id!!, 0, 10) + val secondAgentChannelDonation = service.getChannelDonationByCreator("2026-02-20", "2026-02-20", secondAgent.id!!, 0, 10) + assertChannelDonationSettlementResponse(firstAgentChannelDonation, expectedCount = 1, expectedTotalCan = 10) + assertChannelDonationSettlementResponse(secondAgentChannelDonation, expectedCount = 1, expectedTotalCan = 20) + } + + @Test + @DisplayName("정산 조회는 기간 중 agent 비율 변경이 있어도 거래 시점 기준 비율로 agent 정산금을 계산한다") + fun shouldResolveAgentSettlementRatioAtEventTimeAcrossAllSettlementCategories() { + val agent = saveMember("agent-ratio-history", MemberRole.AGENT) + val creator = saveMember("creator-ratio-history", MemberRole.CREATOR) + val buyer = saveMember("buyer-ratio-history", MemberRole.USER) + + saveRelation( + agent = agent, + creator = creator, + assignedAt = LocalDateTime.of(2026, 2, 1, 0, 0, 0), + unassignedAt = null + ) + saveAgentSettlementRatio(agent, settlementRatio = 10, effectiveFrom = LocalDateTime.of(2026, 2, 1, 0, 0, 0)) + saveAgentSettlementRatio( + agent, + settlementRatio = 20, + effectiveFrom = LocalDateTime.of(2026, 2, 20, 12, 0, 0), + effectiveTo = null, + previousEffectiveTo = LocalDateTime.of(2026, 2, 20, 12, 0, 0) + ) + saveCreatorSettlementRatio(creator, live = 70, content = 70, community = 70) + + val room = saveLiveRoom(creator) + saveLiveUseCan(buyer, room, 10, LocalDateTime.of(2026, 2, 20, 10, 0, 0)) + saveLiveUseCan(buyer, room, 20, LocalDateTime.of(2026, 2, 20, 14, 0, 0)) + + val contentBefore = saveAudioContent(creator, "ratio-content-before", price = 10, settlementRatio = null) + val contentAfter = saveAudioContent(creator, "ratio-content-after", price = 20, settlementRatio = null) + saveOrder(buyer, creator, contentBefore, LocalDateTime.of(2026, 2, 20, 10, 0, 0)) + saveOrder(buyer, creator, contentAfter, LocalDateTime.of(2026, 2, 20, 14, 0, 0)) + + val communityBefore = saveCommunityPost(creator, 10) + val communityAfter = saveCommunityPost(creator, 20) + saveCommunityUseCan(buyer, communityBefore, 10, LocalDateTime.of(2026, 2, 20, 10, 0, 0)) + saveCommunityUseCan(buyer, communityAfter, 20, LocalDateTime.of(2026, 2, 20, 14, 0, 0)) + + saveContentDonationUseCan(buyer, contentBefore, 10, LocalDateTime.of(2026, 2, 20, 10, 0, 0)) + saveContentDonationUseCan(buyer, contentAfter, 20, LocalDateTime.of(2026, 2, 20, 14, 0, 0)) + + val channelBefore = saveChannelDonationUseCan(buyer, 10, LocalDateTime.of(2026, 2, 20, 10, 0, 0)) + saveUseCanCalculate(channelBefore, creator.id!!, 10, PaymentGateway.PG) + val channelAfter = saveChannelDonationUseCan(buyer, 20, LocalDateTime.of(2026, 2, 20, 14, 0, 0)) + saveUseCanCalculate(channelAfter, creator.id!!, 20, PaymentGateway.PG) + + val savedRatios = agentSettlementRatioRepository.findAllByMemberIdOrderByEffectiveFromAsc(agent.id!!) + val startDate = LocalDateTime.of(2026, 2, 20, 0, 0, 0) + val endDate = LocalDateTime.of(2026, 2, 20, 23, 59, 59) + val liveRows = repository.getCalculateLiveByCreator(startDate, endDate, agent.id!!) + assertEquals(savedRatios.mapNotNull { it.id }, liveRows.mapNotNull { it.agentSettlementRatioId }.sorted()) + + val expectedGenericAgentSettlementAmount = + calculateGenericAgentSettlementAmount(totalCan = 10, settlementRatio = 70, agentSettlementRatio = 10) + + calculateGenericAgentSettlementAmount(totalCan = 20, settlementRatio = 70, agentSettlementRatio = 20) + val expectedChannelAgentSettlementAmount = + calculateChannelAgentSettlementAmount(totalCan = 10, agentSettlementRatio = 10) + + calculateChannelAgentSettlementAmount(totalCan = 20, agentSettlementRatio = 20) + + val liveResponse = service.getCalculateLiveByCreator("2026-02-20", "2026-02-20", agent.id!!, 0, 10) + assertGenericSettlementResponse( + liveResponse, + expectedCount = 2, + expectedTotalCan = 30, + expectedAgentSettlementAmount = expectedGenericAgentSettlementAmount + ) + + val contentResponse = service.getCalculateContentByCreator("2026-02-20", "2026-02-20", agent.id!!, 0, 10) + assertGenericSettlementResponse( + contentResponse, + expectedCount = 2, + expectedTotalCan = 30, + expectedAgentSettlementAmount = expectedGenericAgentSettlementAmount + ) + + val communityResponse = service.getCalculateCommunityByCreator("2026-02-20", "2026-02-20", agent.id!!, 0, 10) + assertGenericSettlementResponse( + communityResponse, + expectedCount = 2, + expectedTotalCan = 30, + expectedAgentSettlementAmount = expectedGenericAgentSettlementAmount + ) + + val contentDonationResponse = service.getCalculateContentDonationByCreator("2026-02-20", "2026-02-20", agent.id!!, 0, 10) + assertGenericSettlementResponse( + contentDonationResponse, + expectedCount = 2, + expectedTotalCan = 30, + expectedAgentSettlementAmount = expectedGenericAgentSettlementAmount + ) + + val channelDonationResponse = service.getChannelDonationByCreator("2026-02-20", "2026-02-20", agent.id!!, 0, 10) + assertChannelDonationSettlementResponse( + channelDonationResponse, + expectedCount = 2, + expectedTotalCan = 30, + expectedAgentSettlementAmount = expectedChannelAgentSettlementAmount + ) + } + + private fun saveMember(nickname: String, role: MemberRole): Member { + return memberRepository.saveAndFlush( + Member( + email = "$nickname@test.com", + password = "password", + nickname = nickname, + role = role + ) + ) + } + + private fun saveRelation( + agent: Member, + creator: Member, + assignedAt: LocalDateTime = LocalDateTime.of(2026, 2, 1, 0, 0, 0), + unassignedAt: LocalDateTime? = null + ) { + val relation = AgentCreatorRelation() + relation.agent = agent + relation.creator = creator + relation.assignedAt = assignedAt + relation.unassignedAt = unassignedAt + relationRepository.saveAndFlush(relation) + } + + private fun saveAgentSettlementRatio( + agent: Member, + settlementRatio: Int, + effectiveFrom: LocalDateTime, + effectiveTo: LocalDateTime? = null, + previousEffectiveTo: LocalDateTime? = null + ) { + if (previousEffectiveTo != null) { + val previous = agentSettlementRatioRepository.findFirstByMemberIdAndEffectiveToIsNull(agent.id!!) + previous?.effectiveTo = previousEffectiveTo + previous?.let { agentSettlementRatioRepository.saveAndFlush(it) } + } + + val ratio = AgentSettlementRatio( + settlementRatio = settlementRatio, + effectiveFrom = effectiveFrom + ) + ratio.member = agent + ratio.effectiveTo = effectiveTo + agentSettlementRatioRepository.saveAndFlush(ratio) + } + + private fun saveCreatorSettlementRatio(creator: Member, live: Int, content: Int, community: Int) { + val ratio = CreatorSettlementRatio( + subsidy = 0, + liveSettlementRatio = live, + contentSettlementRatio = content, + communitySettlementRatio = community + ) + ratio.member = creator + creatorSettlementRatioRepository.saveAndFlush(ratio) + } + + private fun saveLiveRoom(creator: Member): LiveRoom { + val room = LiveRoom( + title = "live-room", + notice = "notice", + beginDateTime = LocalDateTime.of(2026, 2, 20, 8, 0, 0), + numberOfPeople = 10, + isAdult = false, + price = 10 + ) + room.member = creator + return liveRoomRepository.saveAndFlush(room) + } + + private fun saveLiveUseCan(sender: Member, room: LiveRoom, can: Int, createdAt: LocalDateTime): UseCan { + val useCan = UseCan( + canUsage = CanUsage.LIVE, + can = can, + rewardCan = 0 + ) + useCan.member = sender + useCan.room = room + val saved = useCanRepository.saveAndFlush(useCan) + updateUseCanCreatedAt(saved.id!!, createdAt) + return saved + } + + private fun saveAudioContent(creator: Member, title: String, price: Int, settlementRatio: Int?): AudioContent { + val theme = AudioContentTheme( + theme = "theme-$title", + image = "image-$title.png" + ) + entityManager.persist(theme) + + val audioContent = AudioContent( + title = title, + detail = "detail-$title", + languageCode = "ko", + price = price, + settlementRatio = settlementRatio + ) + audioContent.theme = theme + audioContent.member = creator + audioContent.isActive = true + return audioContentRepository.saveAndFlush(audioContent) + } + + private fun saveOrder(buyer: Member, creator: Member, content: AudioContent, createdAt: LocalDateTime): Order { + val order = Order(type = OrderType.KEEP) + order.member = buyer + order.creator = creator + order.audioContent = content + val saved = orderRepository.saveAndFlush(order) + updateOrderCreatedAt(saved.id!!, createdAt) + return saved + } + + private fun saveCommunityPost(creator: Member, price: Int): CreatorCommunity { + val post = CreatorCommunity( + content = "community-content-$price", + price = price, + isCommentAvailable = true, + isAdult = false + ) + post.member = creator + return creatorCommunityRepository.saveAndFlush(post) + } + + private fun saveCommunityUseCan(buyer: Member, post: CreatorCommunity, can: Int, createdAt: LocalDateTime): UseCan { + val useCan = UseCan( + canUsage = CanUsage.PAID_COMMUNITY_POST, + can = can, + rewardCan = 0 + ) + useCan.member = buyer + useCan.communityPost = post + val saved = useCanRepository.saveAndFlush(useCan) + updateUseCanCreatedAt(saved.id!!, createdAt) + return saved + } + + private fun saveContentDonationUseCan(buyer: Member, content: AudioContent, can: Int, createdAt: LocalDateTime): UseCan { + val useCan = UseCan( + canUsage = CanUsage.DONATION, + can = can, + rewardCan = 0 + ) + useCan.member = buyer + useCan.audioContent = content + val saved = useCanRepository.saveAndFlush(useCan) + updateUseCanCreatedAt(saved.id!!, createdAt) + return saved + } + + private fun saveChannelDonationUseCan(sender: Member, can: Int, createdAt: LocalDateTime): UseCan { + val useCan = UseCan( + canUsage = CanUsage.CHANNEL_DONATION, + can = can, + rewardCan = 0 + ) + useCan.member = sender + val saved = useCanRepository.saveAndFlush(useCan) + updateUseCanCreatedAt(saved.id!!, createdAt) + return saved + } + + private fun saveUseCanCalculate(useCan: UseCan, recipientCreatorId: Long, can: Int, paymentGateway: PaymentGateway) { + val useCanCalculate = UseCanCalculate( + can = can, + paymentGateway = paymentGateway, + status = UseCanCalculateStatus.RECEIVED + ) + useCanCalculate.useCan = useCan + useCanCalculate.recipientCreatorId = recipientCreatorId + useCanCalculateRepository.saveAndFlush(useCanCalculate) + } + + private fun updateUseCanCreatedAt(useCanId: Long, createdAt: LocalDateTime) { + entityManager.createQuery("update UseCan u set u.createdAt = :createdAt where u.id = :id") + .setParameter("createdAt", createdAt) + .setParameter("id", useCanId) + .executeUpdate() + } + + private fun updateOrderCreatedAt(orderId: Long, createdAt: LocalDateTime) { + entityManager.createQuery("update Order o set o.createdAt = :createdAt where o.id = :id") + .setParameter("createdAt", createdAt) + .setParameter("id", orderId) + .executeUpdate() + } + + private fun registerMysqlDateFunctions() { + entityManager.createNativeQuery( + "CREATE ALIAS IF NOT EXISTS DATE_FORMAT FOR 'kr.co.vividnext.sodalive.support.H2MysqlDateFunctions.dateFormat'" + ).executeUpdate() + entityManager.createNativeQuery( + "CREATE ALIAS IF NOT EXISTS CONVERT_TZ FOR 'kr.co.vividnext.sodalive.support.H2MysqlDateFunctions.convertTz'" + ).executeUpdate() + } + + private fun assertGenericSettlementResponse( + response: GetAgentSettlementByCreatorResponse, + expectedCount: Int, + expectedTotalCan: Int, + expectedAgentSettlementAmount: Int? = null + ) { + assertEquals(1, response.totalCount) + assertEquals(1, response.items.size) + assertEquals(expectedCount, response.total.count) + assertEquals(expectedCount, response.items[0].count) + assertEquals(expectedTotalCan, response.total.totalCan) + assertEquals(expectedTotalCan, response.items[0].totalCan) + expectedAgentSettlementAmount?.let { + assertEquals(it, response.total.agentSettlementAmount) + assertEquals(it, response.items[0].agentSettlementAmount) + } + } + + private fun assertChannelDonationSettlementResponse( + response: GetAgentChannelDonationSettlementByCreatorResponse, + expectedCount: Int, + expectedTotalCan: Int, + expectedAgentSettlementAmount: Int? = null + ) { + assertEquals(1, response.totalCount) + assertEquals(1, response.items.size) + assertEquals(expectedCount, response.total.count) + assertEquals(expectedCount, response.items[0].count) + assertEquals(expectedTotalCan, response.total.totalCan) + assertEquals(expectedTotalCan, response.items[0].totalCan) + expectedAgentSettlementAmount?.let { + assertEquals(it, response.total.agentSettlementAmount) + assertEquals(it, response.items[0].agentSettlementAmount) + } + } + + private fun calculateGenericAgentSettlementAmount(totalCan: Int, settlementRatio: Int, agentSettlementRatio: Int): Int { + val totalKrw = java.math.BigDecimal(totalCan).multiply(java.math.BigDecimal("100")) + val fee = totalKrw.multiply(java.math.BigDecimal("0.066")) + val settlementAmount = totalKrw.subtract(fee) + .multiply(java.math.BigDecimal(settlementRatio).divide(java.math.BigDecimal("100"))) + .setScale(0, java.math.RoundingMode.HALF_UP) + .toInt() + + return java.math.BigDecimal(settlementAmount) + .multiply(java.math.BigDecimal(agentSettlementRatio).divide(java.math.BigDecimal("100"))) + .setScale(0, java.math.RoundingMode.HALF_UP) + .toInt() + } + + private fun calculateChannelAgentSettlementAmount(totalCan: Int, agentSettlementRatio: Int): Int { + val settlementAmount = kr.co.vividnext.sodalive.calculate.channelDonation.ChannelDonationSettlementCalculator + .calculate(totalCan) + .settlementAmount + + return java.math.BigDecimal(settlementAmount) + .multiply(java.math.BigDecimal(agentSettlementRatio).divide(java.math.BigDecimal("100"))) + .setScale(0, java.math.RoundingMode.HALF_UP) + .toInt() + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateServiceTest.kt new file mode 100644 index 00000000..49529aa9 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/partner/agent/calculate/AgentCalculateServiceTest.kt @@ -0,0 +1,633 @@ +package kr.co.vividnext.sodalive.partner.agent.calculate + +import kr.co.vividnext.sodalive.extensions.convertLocalDateTime +import kr.co.vividnext.sodalive.partner.agent.settlement.snapshot.AgentSettlementSnapshot +import kr.co.vividnext.sodalive.partner.agent.settlement.snapshot.AgentSettlementSnapshotRepository +import kr.co.vividnext.sodalive.partner.agent.settlement.snapshot.AgentSettlementSnapshotType +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.mockito.Mockito +import java.time.LocalDateTime + +class AgentCalculateServiceTest { + private lateinit var repository: AgentCalculateQueryRepository + private lateinit var snapshotRepository: AgentSettlementSnapshotRepository + private lateinit var service: AgentCalculateService + + @BeforeEach + fun setup() { + repository = Mockito.mock(AgentCalculateQueryRepository::class.java) + snapshotRepository = Mockito.mock( + AgentSettlementSnapshotRepository::class.java, + Mockito.withSettings().defaultAnswer { invocation -> + if (invocation.method.name == "findAllByPeriodStartAndPeriodEndAndSettlementTypeAndAgentIdOrderByCreatorIdDesc") { + emptyList() + } else { + Mockito.RETURNS_DEFAULTS.answer(invocation) + } + } + ) + service = AgentCalculateService( + repository = repository, + snapshotRepository = snapshotRepository + ) + } + + @Test + @DisplayName("에이전트 서비스는 소속 크리에이터 목록을 페이지 조건으로 조회한다") + fun shouldGetAssignedCreators() { + val items = listOf( + GetAgentAssignedCreatorItem( + creatorId = 21L, + creatorNickname = "creator-a" + ) + ) + + Mockito.`when`( + repository.getAssignedCreatorTotalCount( + Mockito.eq(7L), + Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.now() + ) + ).thenReturn(1) + Mockito.`when`( + repository.getAssignedCreators( + Mockito.eq(7L), + Mockito.eq(10L), + Mockito.eq(5L), + Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.now() + ) + ).thenReturn(items) + + val response = service.getAssignedCreators(agentId = 7L, offset = 10L, limit = 5L) + + assertEquals(1, response.totalCount) + assertEquals(21L, response.items[0].creatorId) + Mockito.verify(repository).getAssignedCreatorTotalCount( + Mockito.eq(7L), + Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.now() + ) + Mockito.verify( + repository + ).getAssignedCreators( + Mockito.eq(7L), + Mockito.eq(10L), + Mockito.eq(5L), + Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.now() + ) + } + + @Test + @DisplayName("에이전트 서비스는 라이브 크리에이터별 응답과 합계에 agent 정산금을 계산한다") + fun shouldBuildLiveSummaryResponse() { + val queryData = listOf( + GetAgentCreatorSettlementSummaryQueryData( + creatorId = 21L, + creatorNickname = "creator-a", + count = 2L, + totalCan = 100, + settlementRatio = 70, + agentSettlementRatio = 10 + ) + ) + + Mockito.`when`( + repository.getCalculateLiveByCreatorTotalCount( + startDate = "2026-02-20".convertLocalDateTime(), + endDate = "2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59), + agentId = 7L + ) + ).thenReturn(1) + Mockito.`when`( + repository.getCalculateLiveByCreator( + startDate = "2026-02-20".convertLocalDateTime(), + endDate = "2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59), + agentId = 7L + ) + ).thenReturn(queryData) + Mockito.`when`( + repository.getCalculateLiveByCreator( + startDate = "2026-02-20".convertLocalDateTime(), + endDate = "2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59), + agentId = 7L, + offset = 0L, + limit = 20L + ) + ).thenReturn(queryData) + + val response = service.getCalculateLiveByCreator( + startDateStr = "2026-02-20", + endDateStr = "2026-02-21", + agentId = 7L, + offset = 0L, + limit = 20L + ) + + assertEquals(1, response.totalCount) + assertEquals(2, response.total.count) + assertEquals(10_000, response.total.krw) + assertEquals(6_538, response.total.settlementAmount) + assertEquals(654, response.total.agentSettlementAmount) + assertEquals(21L, response.items[0].creatorId) + assertEquals(654, response.items[0].agentSettlementAmount) + } + + @Test + @DisplayName("에이전트 서비스는 콘텐츠 크리에이터별 응답을 크리에이터 기준으로 병합하고 agent 정산금을 계산한다") + fun shouldMergeContentSummaryRowsPerCreator() { + val totalRows = listOf( + GetAgentCreatorSettlementSummaryQueryData( + creatorId = 21L, + creatorNickname = "creator-a", + count = 1L, + totalCan = 50, + settlementRatio = 80, + agentSettlementRatio = 10 + ), + GetAgentCreatorSettlementSummaryQueryData( + creatorId = 21L, + creatorNickname = "creator-a", + count = 2L, + totalCan = 30, + settlementRatio = 60, + agentSettlementRatio = 20 + ) + ) + + Mockito.`when`( + repository.getCalculateContentByCreatorTotalCount( + startDate = "2026-02-20".convertLocalDateTime(), + endDate = "2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59), + agentId = 7L + ) + ).thenReturn(1) + Mockito.`when`( + repository.getCalculateContentByCreator( + startDate = "2026-02-20".convertLocalDateTime(), + endDate = "2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59), + agentId = 7L + ) + ).thenReturn(totalRows) + Mockito.`when`( + repository.getCalculateContentByCreator( + startDate = "2026-02-20".convertLocalDateTime(), + endDate = "2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59), + agentId = 7L, + offset = 0L, + limit = 20L + ) + ).thenReturn(totalRows) + + val response = service.getCalculateContentByCreator( + startDateStr = "2026-02-20", + endDateStr = "2026-02-21", + agentId = 7L, + offset = 0L, + limit = 20L + ) + + assertEquals(1, response.totalCount) + assertEquals(3, response.total.count) + assertEquals(80, response.total.totalCan) + assertEquals(5_417, response.total.settlementAmount) + assertEquals(710, response.total.agentSettlementAmount) + assertEquals(3, response.items[0].count) + assertEquals(80, response.items[0].totalCan) + assertEquals(710, response.items[0].agentSettlementAmount) + } + + @Test + @DisplayName("에이전트 서비스는 커뮤니티 크리에이터별 응답과 합계에 agent 정산금을 계산한다") + fun shouldBuildCommunitySummaryResponse() { + val queryData = listOf( + GetAgentCreatorSettlementSummaryQueryData( + creatorId = 21L, + creatorNickname = "creator-a", + count = 2L, + totalCan = 30, + settlementRatio = 60, + agentSettlementRatio = 10 + ) + ) + + Mockito.`when`( + repository.getCalculateCommunityByCreatorTotalCount( + startDate = "2026-02-20".convertLocalDateTime(), + endDate = "2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59), + agentId = 7L + ) + ).thenReturn(1) + Mockito.`when`( + repository.getCalculateCommunityByCreator( + startDate = "2026-02-20".convertLocalDateTime(), + endDate = "2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59), + agentId = 7L + ) + ).thenReturn(queryData) + Mockito.`when`( + repository.getCalculateCommunityByCreator( + startDate = "2026-02-20".convertLocalDateTime(), + endDate = "2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59), + agentId = 7L, + offset = 0L, + limit = 20L + ) + ).thenReturn(queryData) + + val response = service.getCalculateCommunityByCreator( + startDateStr = "2026-02-20", + endDateStr = "2026-02-21", + agentId = 7L, + offset = 0L, + limit = 20L + ) + + assertEquals(1, response.totalCount) + assertEquals(1_681, response.total.settlementAmount) + assertEquals(168, response.total.agentSettlementAmount) + assertEquals(168, response.items[0].agentSettlementAmount) + } + + @Test + @DisplayName("에이전트 서비스는 콘텐츠후원 크리에이터별 응답과 합계에 agent 정산금을 계산한다") + fun shouldBuildContentDonationSummaryResponse() { + val queryData = listOf( + GetAgentCreatorSettlementSummaryQueryData( + creatorId = 21L, + creatorNickname = "creator-a", + count = 2L, + totalCan = 20, + settlementRatio = null, + agentSettlementRatio = 10 + ) + ) + + Mockito.`when`( + repository.getCalculateContentDonationByCreatorTotalCount( + startDate = "2026-02-20".convertLocalDateTime(), + endDate = "2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59), + agentId = 7L + ) + ).thenReturn(1) + Mockito.`when`( + repository.getCalculateContentDonationByCreator( + startDate = "2026-02-20".convertLocalDateTime(), + endDate = "2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59), + agentId = 7L + ) + ).thenReturn(queryData) + Mockito.`when`( + repository.getCalculateContentDonationByCreator( + startDate = "2026-02-20".convertLocalDateTime(), + endDate = "2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59), + agentId = 7L, + offset = 0L, + limit = 20L + ) + ).thenReturn(queryData) + + val response = service.getCalculateContentDonationByCreator( + startDateStr = "2026-02-20", + endDateStr = "2026-02-21", + agentId = 7L, + offset = 0L, + limit = 20L + ) + + assertEquals(1, response.totalCount) + assertEquals(1_308, response.total.settlementAmount) + assertEquals(131, response.total.agentSettlementAmount) + assertEquals(131, response.items[0].agentSettlementAmount) + } + + @Test + @DisplayName("에이전트 비율 이력이 없으면 일반 정산 응답은 10퍼센트 기본값으로 agent 정산금을 계산한다") + fun shouldApplyDefaultAgentSettlementRatioWhenAgentRatioHistoryDoesNotExist() { + val queryData = listOf( + GetAgentCreatorSettlementSummaryQueryData( + creatorId = 21L, + creatorNickname = "creator-a", + count = 2L, + totalCan = 20, + settlementRatio = null, + agentSettlementRatio = null + ) + ) + + Mockito.`when`( + repository.getCalculateContentDonationByCreatorTotalCount( + startDate = "2026-02-20".convertLocalDateTime(), + endDate = "2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59), + agentId = 7L + ) + ).thenReturn(1) + Mockito.`when`( + repository.getCalculateContentDonationByCreator( + startDate = "2026-02-20".convertLocalDateTime(), + endDate = "2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59), + agentId = 7L + ) + ).thenReturn(queryData) + Mockito.`when`( + repository.getCalculateContentDonationByCreator( + startDate = "2026-02-20".convertLocalDateTime(), + endDate = "2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59), + agentId = 7L, + offset = 0L, + limit = 20L + ) + ).thenReturn(queryData) + + val response = service.getCalculateContentDonationByCreator( + startDateStr = "2026-02-20", + endDateStr = "2026-02-21", + agentId = 7L, + offset = 0L, + limit = 20L + ) + + assertEquals(1, response.totalCount) + assertEquals(1_308, response.total.settlementAmount) + assertEquals(131, response.total.agentSettlementAmount) + assertEquals(131, response.items[0].agentSettlementAmount) + } + + @Test + @DisplayName("에이전트 서비스는 채널후원 크리에이터별 응답과 합계에 agent 정산금을 계산한다") + fun shouldBuildChannelDonationSummaryResponse() { + val queryData = listOf( + GetAgentChannelDonationSettlementByCreatorQueryData( + creatorId = 21L, + creatorNickname = "creator-a", + count = 1L, + totalCan = 50, + agentSettlementRatio = 10 + ) + ) + + Mockito.`when`( + repository.getChannelDonationByCreatorTotalCount( + startDate = "2026-02-20".convertLocalDateTime(), + endDate = "2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59), + agentId = 7L + ) + ).thenReturn(1) + Mockito.`when`( + repository.getChannelDonationByCreator( + startDate = "2026-02-20".convertLocalDateTime(), + endDate = "2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59), + agentId = 7L + ) + ).thenReturn(queryData) + Mockito.`when`( + repository.getChannelDonationByCreator( + startDate = "2026-02-20".convertLocalDateTime(), + endDate = "2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59), + agentId = 7L, + offset = 0L, + limit = 20L + ) + ).thenReturn(queryData) + + val response = service.getChannelDonationByCreator( + startDateStr = "2026-02-20", + endDateStr = "2026-02-21", + agentId = 7L, + offset = 0L, + limit = 20L + ) + + assertEquals(1, response.totalCount) + assertEquals(3_970, response.total.settlementAmount) + assertEquals(397, response.total.agentSettlementAmount) + assertEquals(397, response.items[0].agentSettlementAmount) + } + + @Test + @DisplayName("에이전트 비율 이력이 없으면 채널후원 응답은 10퍼센트 기본값으로 agent 정산금을 계산한다") + fun shouldApplyDefaultAgentSettlementRatioToChannelDonationWhenAgentRatioHistoryDoesNotExist() { + val queryData = listOf( + GetAgentChannelDonationSettlementByCreatorQueryData( + creatorId = 21L, + creatorNickname = "creator-a", + count = 1L, + totalCan = 50, + agentSettlementRatio = null + ) + ) + + Mockito.`when`( + repository.getChannelDonationByCreatorTotalCount( + startDate = "2026-02-20".convertLocalDateTime(), + endDate = "2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59), + agentId = 7L + ) + ).thenReturn(1) + Mockito.`when`( + repository.getChannelDonationByCreator( + startDate = "2026-02-20".convertLocalDateTime(), + endDate = "2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59), + agentId = 7L + ) + ).thenReturn(queryData) + Mockito.`when`( + repository.getChannelDonationByCreator( + startDate = "2026-02-20".convertLocalDateTime(), + endDate = "2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59), + agentId = 7L, + offset = 0L, + limit = 20L + ) + ).thenReturn(queryData) + + val response = service.getChannelDonationByCreator( + startDateStr = "2026-02-20", + endDateStr = "2026-02-21", + agentId = 7L, + offset = 0L, + limit = 20L + ) + + assertEquals(1, response.totalCount) + assertEquals(3_970, response.total.settlementAmount) + assertEquals(397, response.total.agentSettlementAmount) + assertEquals(397, response.items[0].agentSettlementAmount) + } + + @Test + @DisplayName("에이전트 서비스는 finalized 기간이면 다섯 카테고리 모두 스냅샷을 우선 사용한다") + fun shouldUseFinalizedSnapshotsFirstAcrossAllSettlementCategories() { + val startDate = "2026-02-20".convertLocalDateTime() + val endDate = "2026-02-21".convertLocalDateTime(hour = 23, minute = 59, second = 59) + + Mockito.`when`( + snapshotRepository.findAllByPeriodStartAndPeriodEndAndSettlementTypeAndAgentIdOrderByCreatorIdDesc( + startDate, + endDate, + AgentSettlementSnapshotType.LIVE, + 7L + ) + ).thenReturn( + listOf( + createSnapshot( + settlementType = AgentSettlementSnapshotType.LIVE, + count = 2, + totalCan = 100, + krw = 10_000, + fee = 660, + settlementAmount = 6_538, + tax = 216, + depositAmount = 6_322, + agentSettlementAmount = 654, + appliedAgentSettlementRatio = 10 + ) + ) + ) + Mockito.`when`( + snapshotRepository.findAllByPeriodStartAndPeriodEndAndSettlementTypeAndAgentIdOrderByCreatorIdDesc( + startDate, + endDate, + AgentSettlementSnapshotType.CONTENT, + 7L + ) + ).thenReturn( + listOf( + createSnapshot( + settlementType = AgentSettlementSnapshotType.CONTENT, + count = 3, + totalCan = 80, + krw = 8_000, + fee = 528, + settlementAmount = 5_417, + tax = 179, + depositAmount = 5_238, + agentSettlementAmount = 710, + appliedAgentSettlementRatio = null + ) + ) + ) + Mockito.`when`( + snapshotRepository.findAllByPeriodStartAndPeriodEndAndSettlementTypeAndAgentIdOrderByCreatorIdDesc( + startDate, + endDate, + AgentSettlementSnapshotType.COMMUNITY, + 7L + ) + ).thenReturn( + listOf( + createSnapshot( + settlementType = AgentSettlementSnapshotType.COMMUNITY, + count = 2, + totalCan = 30, + krw = 3_000, + fee = 198, + settlementAmount = 1_681, + tax = 55, + depositAmount = 1_626, + agentSettlementAmount = 168, + appliedAgentSettlementRatio = 10 + ) + ) + ) + Mockito.`when`( + snapshotRepository.findAllByPeriodStartAndPeriodEndAndSettlementTypeAndAgentIdOrderByCreatorIdDesc( + startDate, + endDate, + AgentSettlementSnapshotType.CONTENT_DONATION, + 7L + ) + ).thenReturn( + listOf( + createSnapshot( + settlementType = AgentSettlementSnapshotType.CONTENT_DONATION, + count = 2, + totalCan = 20, + krw = 2_000, + fee = 132, + settlementAmount = 1_308, + tax = 43, + depositAmount = 1_265, + agentSettlementAmount = 131, + appliedAgentSettlementRatio = 10 + ) + ) + ) + Mockito.`when`( + snapshotRepository.findAllByPeriodStartAndPeriodEndAndSettlementTypeAndAgentIdOrderByCreatorIdDesc( + startDate, + endDate, + AgentSettlementSnapshotType.CHANNEL_DONATION, + 7L + ) + ).thenReturn( + listOf( + createSnapshot( + settlementType = AgentSettlementSnapshotType.CHANNEL_DONATION, + count = 1, + totalCan = 50, + krw = 5_000, + fee = 330, + settlementAmount = 3_970, + tax = 131, + depositAmount = 3_839, + agentSettlementAmount = 397, + appliedAgentSettlementRatio = 10 + ) + ) + ) + + val liveResponse = service.getCalculateLiveByCreator("2026-02-20", "2026-02-21", 7L, 0L, 20L) + val contentResponse = service.getCalculateContentByCreator("2026-02-20", "2026-02-21", 7L, 0L, 20L) + val communityResponse = service.getCalculateCommunityByCreator("2026-02-20", "2026-02-21", 7L, 0L, 20L) + val contentDonationResponse = service.getCalculateContentDonationByCreator("2026-02-20", "2026-02-21", 7L, 0L, 20L) + val channelDonationResponse = service.getChannelDonationByCreator("2026-02-20", "2026-02-21", 7L, 0L, 20L) + + assertEquals(654, liveResponse.total.agentSettlementAmount) + assertEquals(710, contentResponse.total.agentSettlementAmount) + assertEquals(168, communityResponse.total.agentSettlementAmount) + assertEquals(131, contentDonationResponse.total.agentSettlementAmount) + assertEquals(397, channelDonationResponse.total.agentSettlementAmount) + assertEquals(6_538, liveResponse.items[0].settlementAmount) + assertEquals(5_417, contentResponse.items[0].settlementAmount) + assertEquals(1_681, communityResponse.items[0].settlementAmount) + assertEquals(1_308, contentDonationResponse.items[0].settlementAmount) + assertEquals(3_970, channelDonationResponse.items[0].settlementAmount) + assertEquals(131, channelDonationResponse.items[0].withholdingTax) + Mockito.verifyNoInteractions(repository) + } + + private fun createSnapshot( + settlementType: AgentSettlementSnapshotType, + count: Int, + totalCan: Int, + krw: Int, + fee: Int, + settlementAmount: Int, + tax: Int, + depositAmount: Int, + agentSettlementAmount: Int, + appliedAgentSettlementRatio: Int? + ): AgentSettlementSnapshot { + return AgentSettlementSnapshot( + periodStart = LocalDateTime.of(2026, 2, 20, 0, 0, 0), + periodEnd = LocalDateTime.of(2026, 2, 21, 23, 59, 59), + settlementType = settlementType, + agentId = 7L, + agentNickname = "agent-a", + creatorId = 21L, + creatorNickname = "creator-a", + appliedAgentSettlementRatio = appliedAgentSettlementRatio, + count = count, + totalCan = totalCan, + krw = krw, + fee = fee, + settlementAmount = settlementAmount, + tax = tax, + depositAmount = depositAmount, + agentSettlementAmount = agentSettlementAmount, + finalizedAt = LocalDateTime.of(2026, 2, 22, 0, 0, 0), + finalizedByMemberId = 1L + ) + } +}