diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/settlement/AdminAgentSettlementSnapshotController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/settlement/AdminAgentSettlementSnapshotController.kt new file mode 100644 index 00000000..f6bdb694 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/settlement/AdminAgentSettlementSnapshotController.kt @@ -0,0 +1,26 @@ +package kr.co.vividnext.sodalive.admin.partner.agent.settlement + +import kr.co.vividnext.sodalive.common.ApiResponse +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.partner.agent.settlement.snapshot.FinalizeAgentSettlementSnapshotRequest +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@PreAuthorize("hasRole('ADMIN')") +@RequestMapping("/admin/partner/agent/settlement") +class AdminAgentSettlementSnapshotController(private val service: AdminAgentSettlementSnapshotService) { + @PostMapping("/finalize") + fun finalizeSettlement( + @RequestBody request: FinalizeAgentSettlementSnapshotRequest, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + val admin = member ?: throw SodaException(messageKey = "common.error.bad_credentials") + ApiResponse.ok(service.finalizeSnapshots(request, admin.id!!)) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/settlement/AdminAgentSettlementSnapshotService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/settlement/AdminAgentSettlementSnapshotService.kt new file mode 100644 index 00000000..6da6cb9a --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/settlement/AdminAgentSettlementSnapshotService.kt @@ -0,0 +1,293 @@ +package kr.co.vividnext.sodalive.admin.partner.agent.settlement + +import kr.co.vividnext.sodalive.common.SodaException +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.calculate.AgentCalculateQueryRepository +import kr.co.vividnext.sodalive.partner.agent.calculate.GetAgentChannelDonationSettlementByCreatorItem +import kr.co.vividnext.sodalive.partner.agent.calculate.GetAgentChannelDonationSettlementByCreatorQueryData +import kr.co.vividnext.sodalive.partner.agent.calculate.GetAgentCreatorSettlementSummaryQueryData +import kr.co.vividnext.sodalive.partner.agent.calculate.GetAgentSettlementByCreatorItem +import kr.co.vividnext.sodalive.partner.agent.calculate.toMergedResponseItems +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.AgentSettlementSnapshotSourceDetail +import kr.co.vividnext.sodalive.partner.agent.settlement.snapshot.AgentSettlementSnapshotSourceDetailRepository +import kr.co.vividnext.sodalive.partner.agent.settlement.snapshot.AgentSettlementSnapshotType +import kr.co.vividnext.sodalive.partner.agent.settlement.snapshot.FinalizeAgentSettlementSnapshotRequest +import kr.co.vividnext.sodalive.partner.agent.settlement.snapshot.FinalizeAgentSettlementSnapshotResponse +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime + +@Service +class AdminAgentSettlementSnapshotService( + private val snapshotRepository: AgentSettlementSnapshotRepository, + private val sourceDetailRepository: AgentSettlementSnapshotSourceDetailRepository, + private val calculateRepository: AgentCalculateQueryRepository, + private val memberRepository: MemberRepository +) { + private data class SourceDetailDraft( + val creatorId: Long, + val assignmentId: Long?, + val agentSettlementRatioId: Long?, + val appliedAgentSettlementRatio: Int?, + val count: Int, + val totalCan: Int, + val krw: Int, + val fee: Int, + val settlementAmount: Int, + val tax: Int, + val depositAmount: Int, + val agentSettlementAmount: Int + ) + + private data class SnapshotFinalizeDraft( + val snapshots: List, + val sourceDetailsByCreatorId: Map> + ) + + @Transactional + fun finalizeSnapshots( + request: FinalizeAgentSettlementSnapshotRequest, + finalizedByMemberId: Long + ): FinalizeAgentSettlementSnapshotResponse { + val (startDate, endDate) = request.toDateRange() + if ( + snapshotRepository.existsByPeriodStartAndPeriodEndAndSettlementTypeAndAgentId( + periodStart = startDate, + periodEnd = endDate, + settlementType = request.settlementType, + agentId = request.agentId + ) + ) { + return FinalizeAgentSettlementSnapshotResponse(finalizedCount = 0, alreadyFinalized = true) + } + + val agent = getAgent(request.agentId) + val finalizedAt = LocalDateTime.now() + + val draft = when (request.settlementType) { + AgentSettlementSnapshotType.LIVE -> buildGenericSnapshots( + request = request, + agent = agent, + finalizedAt = finalizedAt, + finalizedByMemberId = finalizedByMemberId, + rows = calculateRepository.getCalculateLiveByCreator(startDate, endDate, request.agentId) + ) + + AgentSettlementSnapshotType.CONTENT -> buildGenericSnapshots( + request = request, + agent = agent, + finalizedAt = finalizedAt, + finalizedByMemberId = finalizedByMemberId, + rows = calculateRepository.getCalculateContentByCreator(startDate, endDate, request.agentId) + ) + + AgentSettlementSnapshotType.COMMUNITY -> buildGenericSnapshots( + request = request, + agent = agent, + finalizedAt = finalizedAt, + finalizedByMemberId = finalizedByMemberId, + rows = calculateRepository.getCalculateCommunityByCreator(startDate, endDate, request.agentId) + ) + + AgentSettlementSnapshotType.CONTENT_DONATION -> buildGenericSnapshots( + request = request, + agent = agent, + finalizedAt = finalizedAt, + finalizedByMemberId = finalizedByMemberId, + rows = calculateRepository.getCalculateContentDonationByCreator(startDate, endDate, request.agentId) + ) + + AgentSettlementSnapshotType.CHANNEL_DONATION -> buildChannelSnapshots( + request = request, + agent = agent, + finalizedAt = finalizedAt, + finalizedByMemberId = finalizedByMemberId, + rows = calculateRepository.getChannelDonationByCreator(startDate, endDate, request.agentId) + ) + } + + if (draft.snapshots.isNotEmpty()) { + snapshotRepository.saveAll(draft.snapshots) + sourceDetailRepository.saveAll(buildSourceDetails(draft)) + } + + return FinalizeAgentSettlementSnapshotResponse(finalizedCount = draft.snapshots.size, alreadyFinalized = false) + } + + private fun getAgent(agentId: Long): Member { + val agent = memberRepository.findByIdOrNull(agentId) + ?: throw SodaException(messageKey = "partner.agent.ratio.agent_not_found") + if (agent.role != MemberRole.AGENT) { + throw SodaException(messageKey = "partner.agent.ratio.invalid_agent") + } + return agent + } + + private fun buildGenericSnapshots( + request: FinalizeAgentSettlementSnapshotRequest, + agent: Member, + finalizedAt: LocalDateTime, + finalizedByMemberId: Long, + rows: List + ): SnapshotFinalizeDraft { + val (startDate, endDate) = request.toDateRange() + val rowsByCreator = rows.groupBy { it.creatorId } + val snapshots = rowsByCreator.map { (creatorId, creatorRows) -> + val merged = creatorRows.toMergedResponseItems().single() + val singleSourceRow = creatorRows.singleOrNull() + + AgentSettlementSnapshot( + periodStart = startDate, + periodEnd = endDate, + settlementType = request.settlementType, + agentId = agent.id!!, + agentNickname = agent.nickname, + creatorId = creatorId, + creatorNickname = merged.creatorNickname, + assignmentId = singleSourceRow?.assignmentId, + agentSettlementRatioId = singleSourceRow?.agentSettlementRatioId, + appliedAgentSettlementRatio = singleSourceRow?.agentSettlementRatio, + count = merged.count, + totalCan = merged.totalCan, + krw = merged.krw, + fee = merged.fee, + settlementAmount = merged.settlementAmount, + tax = merged.tax, + depositAmount = merged.depositAmount, + agentSettlementAmount = merged.agentSettlementAmount, + finalizedAt = finalizedAt, + finalizedByMemberId = finalizedByMemberId + ) + } + + return SnapshotFinalizeDraft( + snapshots = snapshots, + sourceDetailsByCreatorId = rowsByCreator.mapValues { (_, creatorRows) -> + creatorRows.map { row -> + row.toResponseItem().toSourceDetailDraft( + assignmentId = row.assignmentId, + agentSettlementRatioId = row.agentSettlementRatioId, + appliedAgentSettlementRatio = row.agentSettlementRatio + ) + } + } + ) + } + + private fun buildChannelSnapshots( + request: FinalizeAgentSettlementSnapshotRequest, + agent: Member, + finalizedAt: LocalDateTime, + finalizedByMemberId: Long, + rows: List + ): SnapshotFinalizeDraft { + val (startDate, endDate) = request.toDateRange() + val rowsByCreator = rows.groupBy { it.creatorId } + val snapshots = rowsByCreator.map { (creatorId, creatorRows) -> + val merged = creatorRows.toMergedResponseItems().single() + val singleSourceRow = creatorRows.singleOrNull() + + AgentSettlementSnapshot( + periodStart = startDate, + periodEnd = endDate, + settlementType = request.settlementType, + agentId = agent.id!!, + agentNickname = agent.nickname, + creatorId = creatorId, + creatorNickname = merged.creatorNickname, + assignmentId = singleSourceRow?.assignmentId, + agentSettlementRatioId = singleSourceRow?.agentSettlementRatioId, + appliedAgentSettlementRatio = singleSourceRow?.agentSettlementRatio, + count = merged.count, + totalCan = merged.totalCan, + krw = merged.krw, + fee = merged.fee, + settlementAmount = merged.settlementAmount, + tax = merged.withholdingTax, + depositAmount = merged.depositAmount, + agentSettlementAmount = merged.agentSettlementAmount, + finalizedAt = finalizedAt, + finalizedByMemberId = finalizedByMemberId + ) + } + + return SnapshotFinalizeDraft( + snapshots = snapshots, + sourceDetailsByCreatorId = rowsByCreator.mapValues { (_, creatorRows) -> + creatorRows.map { row -> + row.toResponseItem().toSourceDetailDraft( + assignmentId = row.assignmentId, + agentSettlementRatioId = row.agentSettlementRatioId, + appliedAgentSettlementRatio = row.agentSettlementRatio + ) + } + } + ) + } + + private fun buildSourceDetails(draft: SnapshotFinalizeDraft): List { + val snapshotsByCreatorId = draft.snapshots.associateBy { it.creatorId } + return draft.sourceDetailsByCreatorId.flatMap { (creatorId, sourceDetails) -> + val snapshot = snapshotsByCreatorId.getValue(creatorId) + sourceDetails.map { detail -> + AgentSettlementSnapshotSourceDetail( + snapshot = snapshot, + assignmentId = detail.assignmentId, + agentSettlementRatioId = detail.agentSettlementRatioId, + appliedAgentSettlementRatio = detail.appliedAgentSettlementRatio, + count = detail.count, + totalCan = detail.totalCan, + krw = detail.krw, + fee = detail.fee, + settlementAmount = detail.settlementAmount, + tax = detail.tax, + depositAmount = detail.depositAmount, + agentSettlementAmount = detail.agentSettlementAmount + ) + } + } + } + + private fun GetAgentSettlementByCreatorItem.toSourceDetailDraft( + assignmentId: Long?, + agentSettlementRatioId: Long?, + appliedAgentSettlementRatio: Int? + ) = SourceDetailDraft( + creatorId = creatorId, + assignmentId = assignmentId, + agentSettlementRatioId = agentSettlementRatioId, + appliedAgentSettlementRatio = appliedAgentSettlementRatio, + count = count, + totalCan = totalCan, + krw = krw, + fee = fee, + settlementAmount = settlementAmount, + tax = tax, + depositAmount = depositAmount, + agentSettlementAmount = agentSettlementAmount + ) + + private fun GetAgentChannelDonationSettlementByCreatorItem.toSourceDetailDraft( + assignmentId: Long?, + agentSettlementRatioId: Long?, + appliedAgentSettlementRatio: Int? + ) = SourceDetailDraft( + creatorId = creatorId, + assignmentId = assignmentId, + agentSettlementRatioId = agentSettlementRatioId, + appliedAgentSettlementRatio = appliedAgentSettlementRatio, + count = count, + totalCan = totalCan, + krw = krw, + fee = fee, + settlementAmount = settlementAmount, + tax = withholdingTax, + depositAmount = depositAmount, + agentSettlementAmount = agentSettlementAmount + ) +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/settlement/snapshot/AgentSettlementSnapshot.kt b/src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/settlement/snapshot/AgentSettlementSnapshot.kt new file mode 100644 index 00000000..d2298a3e --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/settlement/snapshot/AgentSettlementSnapshot.kt @@ -0,0 +1,80 @@ +package kr.co.vividnext.sodalive.partner.agent.settlement.snapshot + +import kr.co.vividnext.sodalive.common.BaseEntity +import java.time.LocalDateTime +import javax.persistence.Column +import javax.persistence.Entity +import javax.persistence.EnumType +import javax.persistence.Enumerated + +@Entity +class AgentSettlementSnapshot( + @Column(nullable = false, updatable = false) + var periodStart: LocalDateTime, + + @Column(nullable = false, updatable = false) + var periodEnd: LocalDateTime, + + @Column(nullable = false, updatable = false) + @Enumerated(EnumType.STRING) + var settlementType: AgentSettlementSnapshotType, + + @Column(nullable = false, updatable = false) + var agentId: Long, + + @Column(nullable = false, updatable = false) + var agentNickname: String, + + @Column(nullable = false, updatable = false) + var creatorId: Long, + + @Column(nullable = false, updatable = false) + var creatorNickname: String, + + @Column(nullable = true, updatable = false) + var assignmentId: Long? = null, + + @Column(nullable = true, updatable = false) + var agentSettlementRatioId: Long? = null, + + @Column(nullable = true, updatable = false) + var appliedAgentSettlementRatio: Int? = null, + + @Column(nullable = false, updatable = false) + var count: Int, + + @Column(nullable = false, updatable = false) + var totalCan: Int, + + @Column(nullable = false, updatable = false) + var krw: Int, + + @Column(nullable = false, updatable = false) + var fee: Int, + + @Column(nullable = false, updatable = false) + var settlementAmount: Int, + + @Column(nullable = false, updatable = false) + var tax: Int, + + @Column(nullable = false, updatable = false) + var depositAmount: Int, + + @Column(nullable = false, updatable = false) + var agentSettlementAmount: Int, + + @Column(nullable = false, updatable = false) + var finalizedAt: LocalDateTime, + + @Column(nullable = false, updatable = false) + var finalizedByMemberId: Long +) : BaseEntity() + +enum class AgentSettlementSnapshotType { + LIVE, + CONTENT, + COMMUNITY, + CHANNEL_DONATION, + CONTENT_DONATION +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/settlement/snapshot/AgentSettlementSnapshotRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/settlement/snapshot/AgentSettlementSnapshotRepository.kt new file mode 100644 index 00000000..839a3353 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/settlement/snapshot/AgentSettlementSnapshotRepository.kt @@ -0,0 +1,20 @@ +package kr.co.vividnext.sodalive.partner.agent.settlement.snapshot + +import org.springframework.data.jpa.repository.JpaRepository +import java.time.LocalDateTime + +interface AgentSettlementSnapshotRepository : JpaRepository { + fun existsByPeriodStartAndPeriodEndAndSettlementTypeAndAgentId( + periodStart: LocalDateTime, + periodEnd: LocalDateTime, + settlementType: AgentSettlementSnapshotType, + agentId: Long + ): Boolean + + fun findAllByPeriodStartAndPeriodEndAndSettlementTypeAndAgentIdOrderByCreatorIdDesc( + periodStart: LocalDateTime, + periodEnd: LocalDateTime, + settlementType: AgentSettlementSnapshotType, + agentId: Long + ): List +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/settlement/snapshot/AgentSettlementSnapshotResponseMapper.kt b/src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/settlement/snapshot/AgentSettlementSnapshotResponseMapper.kt new file mode 100644 index 00000000..bca7b7f1 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/settlement/snapshot/AgentSettlementSnapshotResponseMapper.kt @@ -0,0 +1,39 @@ +package kr.co.vividnext.sodalive.partner.agent.settlement.snapshot + +import kr.co.vividnext.sodalive.partner.agent.calculate.GetAgentChannelDonationSettlementByCreatorItem +import kr.co.vividnext.sodalive.partner.agent.calculate.GetAgentSettlementByCreatorItem + +fun List.toSettlementByCreatorItems(): List { + return map { + GetAgentSettlementByCreatorItem( + creatorId = it.creatorId, + creatorNickname = it.creatorNickname, + count = it.count, + totalCan = it.totalCan, + krw = it.krw, + fee = it.fee, + settlementAmount = it.settlementAmount, + tax = it.tax, + depositAmount = it.depositAmount, + agentSettlementAmount = it.agentSettlementAmount + ) + } +} + +fun List.toChannelDonationSettlementByCreatorItems(): + List { + return map { + GetAgentChannelDonationSettlementByCreatorItem( + creatorId = it.creatorId, + creatorNickname = it.creatorNickname, + count = it.count, + totalCan = it.totalCan, + krw = it.krw, + fee = it.fee, + settlementAmount = it.settlementAmount, + withholdingTax = it.tax, + depositAmount = it.depositAmount, + agentSettlementAmount = it.agentSettlementAmount + ) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/settlement/snapshot/AgentSettlementSnapshotSourceDetail.kt b/src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/settlement/snapshot/AgentSettlementSnapshotSourceDetail.kt new file mode 100644 index 00000000..beb12f58 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/settlement/snapshot/AgentSettlementSnapshotSourceDetail.kt @@ -0,0 +1,48 @@ +package kr.co.vividnext.sodalive.partner.agent.settlement.snapshot + +import kr.co.vividnext.sodalive.common.BaseEntity +import javax.persistence.Column +import javax.persistence.Entity +import javax.persistence.FetchType +import javax.persistence.JoinColumn +import javax.persistence.ManyToOne + +@Entity +class AgentSettlementSnapshotSourceDetail( + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "snapshot_id", nullable = false) + var snapshot: AgentSettlementSnapshot, + + @Column(nullable = true, updatable = false) + var assignmentId: Long? = null, + + @Column(nullable = true, updatable = false) + var agentSettlementRatioId: Long? = null, + + @Column(nullable = true, updatable = false) + var appliedAgentSettlementRatio: Int? = null, + + @Column(nullable = false, updatable = false) + var count: Int, + + @Column(nullable = false, updatable = false) + var totalCan: Int, + + @Column(nullable = false, updatable = false) + var krw: Int, + + @Column(nullable = false, updatable = false) + var fee: Int, + + @Column(nullable = false, updatable = false) + var settlementAmount: Int, + + @Column(nullable = false, updatable = false) + var tax: Int, + + @Column(nullable = false, updatable = false) + var depositAmount: Int, + + @Column(nullable = false, updatable = false) + var agentSettlementAmount: Int +) : BaseEntity() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/settlement/snapshot/AgentSettlementSnapshotSourceDetailRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/settlement/snapshot/AgentSettlementSnapshotSourceDetailRepository.kt new file mode 100644 index 00000000..a4d08b9f --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/settlement/snapshot/AgentSettlementSnapshotSourceDetailRepository.kt @@ -0,0 +1,5 @@ +package kr.co.vividnext.sodalive.partner.agent.settlement.snapshot + +import org.springframework.data.jpa.repository.JpaRepository + +interface AgentSettlementSnapshotSourceDetailRepository : JpaRepository diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/settlement/snapshot/FinalizeAgentSettlementSnapshotRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/settlement/snapshot/FinalizeAgentSettlementSnapshotRequest.kt new file mode 100644 index 00000000..337e476d --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/settlement/snapshot/FinalizeAgentSettlementSnapshotRequest.kt @@ -0,0 +1,22 @@ +package kr.co.vividnext.sodalive.partner.agent.settlement.snapshot + +import kr.co.vividnext.sodalive.extensions.convertLocalDateTime +import java.time.LocalDateTime + +data class FinalizeAgentSettlementSnapshotRequest( + val agentId: Long, + val settlementType: AgentSettlementSnapshotType, + val startDateStr: String, + val endDateStr: String +) { + fun toDateRange(): Pair { + val startDate = startDateStr.convertLocalDateTime() + val endDate = endDateStr.convertLocalDateTime(hour = 23, minute = 59, second = 59) + return startDate to endDate + } +} + +data class FinalizeAgentSettlementSnapshotResponse( + val finalizedCount: Int, + val alreadyFinalized: Boolean +) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/settlement/AdminAgentSettlementSnapshotControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/settlement/AdminAgentSettlementSnapshotControllerTest.kt new file mode 100644 index 00000000..285cb986 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/settlement/AdminAgentSettlementSnapshotControllerTest.kt @@ -0,0 +1,68 @@ +package kr.co.vividnext.sodalive.admin.partner.agent.settlement + +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.partner.agent.settlement.snapshot.AgentSettlementSnapshotType +import kr.co.vividnext.sodalive.partner.agent.settlement.snapshot.FinalizeAgentSettlementSnapshotRequest +import kr.co.vividnext.sodalive.partner.agent.settlement.snapshot.FinalizeAgentSettlementSnapshotResponse +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 + +class AdminAgentSettlementSnapshotControllerTest { + private lateinit var service: AdminAgentSettlementSnapshotService + private lateinit var controller: AdminAgentSettlementSnapshotController + + @BeforeEach + fun setup() { + service = Mockito.mock(AdminAgentSettlementSnapshotService::class.java) + controller = AdminAgentSettlementSnapshotController(service) + } + + @Test + @DisplayName("인증 사용자 정보가 없으면 관리자 확정 정산 요청은 예외를 던진다") + fun shouldThrowWhenMemberIsNull() { + val request = FinalizeAgentSettlementSnapshotRequest( + agentId = 7L, + settlementType = AgentSettlementSnapshotType.LIVE, + startDateStr = "2026-02-20", + endDateStr = "2026-02-21" + ) + + val exception = assertThrows(SodaException::class.java) { + controller.finalizeSettlement(request = request, member = null) + } + + assertEquals("common.error.bad_credentials", exception.messageKey) + } + + @Test + @DisplayName("관리자 컨트롤러는 확정 정산 요청과 인증 관리자 정보를 서비스로 전달한다") + fun shouldForwardFinalizeRequestToService() { + val request = FinalizeAgentSettlementSnapshotRequest( + agentId = 7L, + settlementType = AgentSettlementSnapshotType.LIVE, + startDateStr = "2026-02-20", + endDateStr = "2026-02-21" + ) + val member = Member(password = "password", nickname = "admin", role = MemberRole.ADMIN) + member.id = 99L + val body = FinalizeAgentSettlementSnapshotResponse( + finalizedCount = 1, + alreadyFinalized = false + ) + + Mockito.`when`(service.finalizeSnapshots(request, 99L)).thenReturn(body) + + val response = controller.finalizeSettlement(request = request, member = member) + + assertEquals(true, response.success) + assertEquals(1, response.data!!.finalizedCount) + assertEquals(false, response.data!!.alreadyFinalized) + Mockito.verify(service).finalizeSnapshots(request, 99L) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/settlement/AdminAgentSettlementSnapshotServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/settlement/AdminAgentSettlementSnapshotServiceTest.kt new file mode 100644 index 00000000..18d100ea --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/settlement/AdminAgentSettlementSnapshotServiceTest.kt @@ -0,0 +1,239 @@ +package kr.co.vividnext.sodalive.admin.partner.agent.settlement + +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.calculate.AgentCalculateQueryRepository +import kr.co.vividnext.sodalive.partner.agent.calculate.GetAgentCreatorSettlementSummaryQueryData +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.AgentSettlementSnapshotSourceDetail +import kr.co.vividnext.sodalive.partner.agent.settlement.snapshot.AgentSettlementSnapshotSourceDetailRepository +import kr.co.vividnext.sodalive.partner.agent.settlement.snapshot.AgentSettlementSnapshotType +import kr.co.vividnext.sodalive.partner.agent.settlement.snapshot.FinalizeAgentSettlementSnapshotRequest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.ArgumentCaptor +import org.mockito.Mockito +import java.util.Optional + +class AdminAgentSettlementSnapshotServiceTest { + private lateinit var snapshotRepository: AgentSettlementSnapshotRepository + private lateinit var sourceDetailRepository: AgentSettlementSnapshotSourceDetailRepository + private lateinit var calculateRepository: AgentCalculateQueryRepository + private lateinit var memberRepository: MemberRepository + private lateinit var service: AdminAgentSettlementSnapshotService + + @BeforeEach + fun setup() { + snapshotRepository = Mockito.mock(AgentSettlementSnapshotRepository::class.java) + sourceDetailRepository = Mockito.mock(AgentSettlementSnapshotSourceDetailRepository::class.java) + calculateRepository = Mockito.mock(AgentCalculateQueryRepository::class.java) + memberRepository = Mockito.mock(MemberRepository::class.java) + service = AdminAgentSettlementSnapshotService( + snapshotRepository = snapshotRepository, + sourceDetailRepository = sourceDetailRepository, + calculateRepository = calculateRepository, + memberRepository = memberRepository + ) + } + + @Test + @DisplayName("관리자는 live creator summary를 immutable 스냅샷으로 확정 저장한다") + fun shouldCreateImmutableSettlementSnapshots() { + val request = FinalizeAgentSettlementSnapshotRequest( + agentId = 7L, + settlementType = AgentSettlementSnapshotType.LIVE, + startDateStr = "2026-02-20", + endDateStr = "2026-02-21" + ) + val agent = Member(password = "password", nickname = "agent-a", role = MemberRole.AGENT) + agent.id = 7L + val (startDate, endDate) = request.toDateRange() + + Mockito.`when`( + snapshotRepository.existsByPeriodStartAndPeriodEndAndSettlementTypeAndAgentId( + startDate, + endDate, + AgentSettlementSnapshotType.LIVE, + 7L + ) + ).thenReturn(false) + Mockito.`when`(memberRepository.findById(7L)).thenReturn(Optional.of(agent)) + Mockito.`when`(calculateRepository.getCalculateLiveByCreator(startDate, endDate, 7L)).thenReturn( + listOf( + GetAgentCreatorSettlementSummaryQueryData( + creatorId = 21L, + creatorNickname = "creator-a", + assignmentId = 101L, + agentSettlementRatioId = 202L, + count = 2L, + totalCan = 100, + settlementRatio = 70, + agentSettlementRatio = 10 + ) + ) + ) + + val response = service.finalizeSnapshots(request, finalizedByMemberId = 99L) + + assertEquals(1, response.finalizedCount) + assertEquals(false, response.alreadyFinalized) + + @Suppress("UNCHECKED_CAST") + val captor = ArgumentCaptor.forClass(List::class.java) as ArgumentCaptor> + Mockito.verify(snapshotRepository).saveAll(captor.capture()) + + val snapshot = captor.value.single() + assertEquals(startDate, snapshot.periodStart) + assertEquals(endDate, snapshot.periodEnd) + assertEquals(AgentSettlementSnapshotType.LIVE, snapshot.settlementType) + assertEquals(7L, snapshot.agentId) + assertEquals("agent-a", snapshot.agentNickname) + assertEquals(21L, snapshot.creatorId) + assertEquals("creator-a", snapshot.creatorNickname) + assertEquals(101L, snapshot.assignmentId) + assertEquals(202L, snapshot.agentSettlementRatioId) + assertEquals(10, snapshot.appliedAgentSettlementRatio) + assertEquals(2, snapshot.count) + assertEquals(100, snapshot.totalCan) + assertEquals(10_000, snapshot.krw) + assertEquals(660, snapshot.fee) + assertEquals(6_538, snapshot.settlementAmount) + assertEquals(216, snapshot.tax) + assertEquals(6_322, snapshot.depositAmount) + assertEquals(654, snapshot.agentSettlementAmount) + assertEquals(99L, snapshot.finalizedByMemberId) + assertNotNull(snapshot.finalizedAt) + + @Suppress("UNCHECKED_CAST") + val detailCaptor = ArgumentCaptor.forClass(List::class.java) as ArgumentCaptor> + Mockito.verify(sourceDetailRepository).saveAll(detailCaptor.capture()) + val detail = detailCaptor.value.single() + assertEquals(101L, detail.assignmentId) + assertEquals(202L, detail.agentSettlementRatioId) + assertEquals(10, detail.appliedAgentSettlementRatio) + assertEquals(2, detail.count) + assertEquals(100, detail.totalCan) + assertEquals(10_000, detail.krw) + assertEquals(660, detail.fee) + assertEquals(6_538, detail.settlementAmount) + assertEquals(216, detail.tax) + assertEquals(6_322, detail.depositAmount) + assertEquals(654, detail.agentSettlementAmount) + } + + @Test + @DisplayName("기간 중 source row가 여러 개면 summary FK는 비우고 source detail로 provenance를 남긴다") + fun shouldStoreMixedPeriodProvenanceInSourceDetails() { + val request = FinalizeAgentSettlementSnapshotRequest( + agentId = 7L, + settlementType = AgentSettlementSnapshotType.LIVE, + startDateStr = "2026-02-20", + endDateStr = "2026-02-21" + ) + val agent = Member(password = "password", nickname = "agent-a", role = MemberRole.AGENT) + agent.id = 7L + val (startDate, endDate) = request.toDateRange() + + Mockito.`when`( + snapshotRepository.existsByPeriodStartAndPeriodEndAndSettlementTypeAndAgentId( + startDate, + endDate, + AgentSettlementSnapshotType.LIVE, + 7L + ) + ).thenReturn(false) + Mockito.`when`(memberRepository.findById(7L)).thenReturn(Optional.of(agent)) + Mockito.`when`(calculateRepository.getCalculateLiveByCreator(startDate, endDate, 7L)).thenReturn( + listOf( + GetAgentCreatorSettlementSummaryQueryData( + creatorId = 21L, + creatorNickname = "creator-a", + assignmentId = 101L, + agentSettlementRatioId = 202L, + count = 1L, + totalCan = 40, + settlementRatio = 70, + agentSettlementRatio = 10 + ), + GetAgentCreatorSettlementSummaryQueryData( + creatorId = 21L, + creatorNickname = "creator-a", + assignmentId = 102L, + agentSettlementRatioId = 203L, + count = 1L, + totalCan = 60, + settlementRatio = 70, + agentSettlementRatio = 20 + ) + ) + ) + + val response = service.finalizeSnapshots(request, finalizedByMemberId = 99L) + + assertEquals(1, response.finalizedCount) + + @Suppress("UNCHECKED_CAST") + val snapshotCaptor = ArgumentCaptor.forClass(List::class.java) as ArgumentCaptor> + Mockito.verify(snapshotRepository).saveAll(snapshotCaptor.capture()) + val snapshot = snapshotCaptor.value.single() + assertEquals(null, snapshot.assignmentId) + assertEquals(null, snapshot.agentSettlementRatioId) + assertEquals(null, snapshot.appliedAgentSettlementRatio) + + @Suppress("UNCHECKED_CAST") + val detailCaptor = ArgumentCaptor.forClass(List::class.java) as ArgumentCaptor> + Mockito.verify(sourceDetailRepository).saveAll(detailCaptor.capture()) + assertEquals(2, detailCaptor.value.size) + assertEquals(listOf(101L, 102L), detailCaptor.value.mapNotNull { it.assignmentId }.sorted()) + assertEquals(listOf(202L, 203L), detailCaptor.value.mapNotNull { it.agentSettlementRatioId }.sorted()) + assertEquals(snapshot.count, detailCaptor.value.sumOf { it.count }) + assertEquals(snapshot.totalCan, detailCaptor.value.sumOf { it.totalCan }) + assertEquals(snapshot.krw, detailCaptor.value.sumOf { it.krw }) + assertEquals(snapshot.fee, detailCaptor.value.sumOf { it.fee }) + assertEquals(snapshot.settlementAmount, detailCaptor.value.sumOf { it.settlementAmount }) + assertEquals(snapshot.tax, detailCaptor.value.sumOf { it.tax }) + assertEquals(snapshot.depositAmount, detailCaptor.value.sumOf { it.depositAmount }) + assertEquals(snapshot.agentSettlementAmount, detailCaptor.value.sumOf { it.agentSettlementAmount }) + } + + @Test + @DisplayName("동일 기간과 타입이 이미 확정되었으면 스냅샷을 중복 저장하지 않는다") + fun shouldSkipSavingWhenSnapshotsAlreadyExist() { + val request = FinalizeAgentSettlementSnapshotRequest( + agentId = 7L, + settlementType = AgentSettlementSnapshotType.LIVE, + startDateStr = "2026-02-20", + endDateStr = "2026-02-21" + ) + val (startDate, endDate) = request.toDateRange() + + Mockito.`when`( + snapshotRepository.existsByPeriodStartAndPeriodEndAndSettlementTypeAndAgentId( + startDate, + endDate, + AgentSettlementSnapshotType.LIVE, + 7L + ) + ).thenReturn(true) + + val response = service.finalizeSnapshots(request, finalizedByMemberId = 99L) + + assertEquals(0, response.finalizedCount) + assertEquals(true, response.alreadyFinalized) + Mockito.verify(snapshotRepository).existsByPeriodStartAndPeriodEndAndSettlementTypeAndAgentId( + startDate, + endDate, + AgentSettlementSnapshotType.LIVE, + 7L + ) + Mockito.verify(snapshotRepository, Mockito.never()).saveAll(Mockito.anyList()) + Mockito.verifyNoInteractions(sourceDetailRepository) + Mockito.verifyNoInteractions(calculateRepository) + Mockito.verifyNoInteractions(memberRepository) + } +}