From d0be8ec2dbafdd2c738c91486486bea6cfa30ee0 Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 10 Apr 2026 02:23:18 +0900 Subject: [PATCH] =?UTF-8?q?feat(agent-ratio):=20=EC=97=90=EC=9D=B4?= =?UTF-8?q?=EC=A0=84=ED=8A=B8=20=EC=A0=95=EC=82=B0=20=EB=B9=84=EC=9C=A8=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20=EA=B8=B0=EB=8A=A5=EC=9D=84=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AdminAgentSettlementRatioController.kt | 32 ++ .../ratio/AdminAgentSettlementRatioService.kt | 108 +++++ .../agent/ratio/AgentSettlementRatio.kt | 27 ++ .../ratio/AgentSettlementRatioRepository.kt | 50 +++ .../CreateAgentSettlementRatioRequest.kt | 14 + .../ratio/GetAgentSettlementRatioResponse.kt | 17 + ...AdminAgentSettlementRatioControllerTest.kt | 81 ++++ .../ratio/AgentSettlementRatioServiceTest.kt | 368 ++++++++++++++++++ 8 files changed, 697 insertions(+) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/ratio/AdminAgentSettlementRatioController.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/ratio/AdminAgentSettlementRatioService.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/ratio/AgentSettlementRatio.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/ratio/AgentSettlementRatioRepository.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/ratio/CreateAgentSettlementRatioRequest.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/ratio/GetAgentSettlementRatioResponse.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/ratio/AdminAgentSettlementRatioControllerTest.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/ratio/AgentSettlementRatioServiceTest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/ratio/AdminAgentSettlementRatioController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/ratio/AdminAgentSettlementRatioController.kt new file mode 100644 index 00000000..90e45494 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/ratio/AdminAgentSettlementRatioController.kt @@ -0,0 +1,32 @@ +package kr.co.vividnext.sodalive.admin.partner.agent.ratio + +import kr.co.vividnext.sodalive.common.ApiResponse +import kr.co.vividnext.sodalive.partner.agent.ratio.CreateAgentSettlementRatioRequest +import org.springframework.data.domain.Pageable +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.web.bind.annotation.GetMapping +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/ratio") +class AdminAgentSettlementRatioController(private val service: AdminAgentSettlementRatioService) { + @PostMapping + fun createAgentSettlementRatio(@RequestBody request: CreateAgentSettlementRatioRequest) = + ApiResponse.ok(service.createAgentSettlementRatio(request)) + + @PostMapping("/update") + fun updateAgentSettlementRatio(@RequestBody request: CreateAgentSettlementRatioRequest) = + ApiResponse.ok(service.updateAgentSettlementRatio(request)) + + @GetMapping + fun getAgentSettlementRatio(pageable: Pageable) = ApiResponse.ok( + service.getAgentSettlementRatio( + offset = pageable.offset, + limit = pageable.pageSize.toLong() + ) + ) +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/ratio/AdminAgentSettlementRatioService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/ratio/AdminAgentSettlementRatioService.kt new file mode 100644 index 00000000..a341437b --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/ratio/AdminAgentSettlementRatioService.kt @@ -0,0 +1,108 @@ +package kr.co.vividnext.sodalive.admin.partner.agent.ratio + +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.member.MemberRepository +import kr.co.vividnext.sodalive.member.MemberRole +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.ratio.CreateAgentSettlementRatioRequest +import kr.co.vividnext.sodalive.partner.agent.ratio.GetAgentSettlementRatioResponse +import org.springframework.dao.DataIntegrityViolationException +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime + +@Service +class AdminAgentSettlementRatioService( + private val repository: AgentSettlementRatioRepository, + private val memberRepository: MemberRepository +) { + @Transactional + fun createAgentSettlementRatio(request: CreateAgentSettlementRatioRequest) { + validateSettlementRatio(request.settlementRatio) + val agent = getAgent(request.memberId) + closeCurrentRatioIfExists(request) + validateClosedHistory(request) + + val ratio = request.toEntity() + ratio.member = agent + saveRatioOrThrow(request, ratio) + } + + @Transactional + fun updateAgentSettlementRatio(request: CreateAgentSettlementRatioRequest) { + validateSettlementRatio(request.settlementRatio) + val agent = getAgent(request.memberId) + + val existing = repository.findFirstByMemberIdAndEffectiveToIsNull(request.memberId) + ?: throw SodaException(messageKey = "partner.agent.ratio.not_found") + validateNewEffectiveFrom(existing.effectiveFrom, request.effectiveFrom) + existing.close(request.effectiveFrom) + repository.save(existing) + + val ratio = request.toEntity() + ratio.member = agent + saveRatioOrThrow(request, ratio) + } + + @Transactional(readOnly = true) + fun getAgentSettlementRatio(offset: Long, limit: Long): GetAgentSettlementRatioResponse { + val totalCount = repository.getAgentSettlementRatioTotalCount() + val items = repository.getAgentSettlementRatio(offset = offset, limit = limit) + return GetAgentSettlementRatioResponse(totalCount = totalCount, items = items) + } + + private fun getAgent(memberId: Long) = memberRepository.findByIdForUpdate(memberId) + ?.also { + if (it.role != MemberRole.AGENT) { + throw SodaException(messageKey = "partner.agent.ratio.invalid_agent") + } + } + ?: throw SodaException(messageKey = "partner.agent.ratio.agent_not_found") + + private fun closeCurrentRatioIfExists(request: CreateAgentSettlementRatioRequest) { + val existing = repository.findFirstByMemberIdAndEffectiveToIsNull(request.memberId) ?: return + validateNewEffectiveFrom(existing.effectiveFrom, request.effectiveFrom) + existing.close(request.effectiveFrom) + repository.save(existing) + } + + private fun validateNewEffectiveFrom( + currentEffectiveFrom: LocalDateTime, + newEffectiveFrom: LocalDateTime + ) { + if (!newEffectiveFrom.isAfter(currentEffectiveFrom)) { + throw SodaException(messageKey = "partner.agent.ratio.invalid_effective_from") + } + } + + private fun validateClosedHistory(request: CreateAgentSettlementRatioRequest) { + val hasOverlap = repository.findAllByMemberIdOrderByEffectiveFromAsc(request.memberId) + .any { history -> + val effectiveTo = history.effectiveTo ?: return@any false + !request.effectiveFrom.isBefore(history.effectiveFrom) && request.effectiveFrom.isBefore(effectiveTo) + } + if (hasOverlap) { + throw SodaException(messageKey = "partner.agent.ratio.invalid_effective_from") + } + } + + private fun validateSettlementRatio(settlementRatio: Int) { + if (settlementRatio !in 0..100) { + throw SodaException(messageKey = "common.error.invalid_request") + } + } + + private fun saveRatioOrThrow( + request: CreateAgentSettlementRatioRequest, + ratio: AgentSettlementRatio + ) { + try { + repository.saveAndFlush(ratio) + } catch (e: DataIntegrityViolationException) { + repository.findFirstByMemberIdAndEffectiveToIsNull(request.memberId) + ?: throw e + throw SodaException(messageKey = "partner.agent.ratio.invalid_effective_from") + } + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/ratio/AgentSettlementRatio.kt b/src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/ratio/AgentSettlementRatio.kt new file mode 100644 index 00000000..175853de --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/ratio/AgentSettlementRatio.kt @@ -0,0 +1,27 @@ +package kr.co.vividnext.sodalive.partner.agent.ratio + +import kr.co.vividnext.sodalive.common.BaseEntity +import kr.co.vividnext.sodalive.member.Member +import java.time.LocalDateTime +import javax.persistence.Column +import javax.persistence.Entity +import javax.persistence.FetchType +import javax.persistence.JoinColumn +import javax.persistence.ManyToOne + +@Entity +class AgentSettlementRatio( + var settlementRatio: Int, + @Column(nullable = false) + var effectiveFrom: LocalDateTime +) : BaseEntity() { + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + var member: Member? = null + + var effectiveTo: LocalDateTime? = null + + fun close(effectiveTo: LocalDateTime) { + this.effectiveTo = effectiveTo + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/ratio/AgentSettlementRatioRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/ratio/AgentSettlementRatioRepository.kt new file mode 100644 index 00000000..ea7fa08e --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/ratio/AgentSettlementRatioRepository.kt @@ -0,0 +1,50 @@ +package kr.co.vividnext.sodalive.partner.agent.ratio + +import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.member.QMember.member +import kr.co.vividnext.sodalive.partner.agent.ratio.QAgentSettlementRatio.agentSettlementRatio +import org.springframework.data.jpa.repository.JpaRepository + +interface AgentSettlementRatioRepository : + JpaRepository, + AgentSettlementRatioQueryRepository { + fun findFirstByMemberIdAndEffectiveToIsNull(memberId: Long): AgentSettlementRatio? + fun findAllByMemberIdOrderByEffectiveFromAsc(memberId: Long): List +} + +interface AgentSettlementRatioQueryRepository { + fun getAgentSettlementRatio(offset: Long, limit: Long): List + fun getAgentSettlementRatioTotalCount(): Int +} + +class AgentSettlementRatioQueryRepositoryImpl( + private val queryFactory: JPAQueryFactory +) : AgentSettlementRatioQueryRepository { + override fun getAgentSettlementRatio(offset: Long, limit: Long): List { + return queryFactory + .select( + QGetAgentSettlementRatioItem( + member.id, + member.nickname, + agentSettlementRatio.settlementRatio, + agentSettlementRatio.effectiveFrom, + agentSettlementRatio.effectiveTo + ) + ) + .from(agentSettlementRatio) + .innerJoin(agentSettlementRatio.member, member) + .orderBy(agentSettlementRatio.id.asc()) + .offset(offset) + .limit(limit) + .fetch() + } + + override fun getAgentSettlementRatioTotalCount(): Int { + return queryFactory + .select(agentSettlementRatio.id.count()) + .from(agentSettlementRatio) + .fetchOne() + ?.toInt() + ?: 0 + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/ratio/CreateAgentSettlementRatioRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/ratio/CreateAgentSettlementRatioRequest.kt new file mode 100644 index 00000000..212db85b --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/ratio/CreateAgentSettlementRatioRequest.kt @@ -0,0 +1,14 @@ +package kr.co.vividnext.sodalive.partner.agent.ratio + +import java.time.LocalDateTime + +data class CreateAgentSettlementRatioRequest( + val memberId: Long, + val settlementRatio: Int, + val effectiveFrom: LocalDateTime +) { + fun toEntity() = AgentSettlementRatio( + settlementRatio = settlementRatio, + effectiveFrom = effectiveFrom + ) +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/ratio/GetAgentSettlementRatioResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/ratio/GetAgentSettlementRatioResponse.kt new file mode 100644 index 00000000..b982bf48 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/partner/agent/ratio/GetAgentSettlementRatioResponse.kt @@ -0,0 +1,17 @@ +package kr.co.vividnext.sodalive.partner.agent.ratio + +import com.querydsl.core.annotations.QueryProjection +import java.time.LocalDateTime + +data class GetAgentSettlementRatioResponse( + val totalCount: Int, + val items: List +) + +data class GetAgentSettlementRatioItem @QueryProjection constructor( + val memberId: Long, + val nickname: String, + val settlementRatio: Int, + val effectiveFrom: LocalDateTime, + val effectiveTo: LocalDateTime? +) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/ratio/AdminAgentSettlementRatioControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/ratio/AdminAgentSettlementRatioControllerTest.kt new file mode 100644 index 00000000..561fc296 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/ratio/AdminAgentSettlementRatioControllerTest.kt @@ -0,0 +1,81 @@ +package kr.co.vividnext.sodalive.admin.partner.agent.ratio + +import kr.co.vividnext.sodalive.partner.agent.ratio.CreateAgentSettlementRatioRequest +import kr.co.vividnext.sodalive.partner.agent.ratio.GetAgentSettlementRatioItem +import kr.co.vividnext.sodalive.partner.agent.ratio.GetAgentSettlementRatioResponse +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 org.springframework.data.domain.PageRequest +import java.time.LocalDateTime + +class AdminAgentSettlementRatioControllerTest { + private lateinit var service: AdminAgentSettlementRatioService + private lateinit var controller: AdminAgentSettlementRatioController + + @BeforeEach + fun setup() { + service = Mockito.mock(AdminAgentSettlementRatioService::class.java) + controller = AdminAgentSettlementRatioController(service) + } + + @Test + @DisplayName("관리자 컨트롤러는 정산 비율 생성 요청을 서비스로 전달한다") + fun shouldForwardCreateRequestToService() { + val request = CreateAgentSettlementRatioRequest( + memberId = 31L, + settlementRatio = 15, + effectiveFrom = LocalDateTime.of(2026, 4, 9, 10, 0) + ) + + val response = controller.createAgentSettlementRatio(request) + + assertEquals(true, response.success) + Mockito.verify(service).createAgentSettlementRatio(request) + } + + @Test + @DisplayName("관리자 컨트롤러는 정산 비율 수정 요청을 서비스로 전달한다") + fun shouldForwardUpdateRequestToService() { + val request = CreateAgentSettlementRatioRequest( + memberId = 31L, + settlementRatio = 18, + effectiveFrom = LocalDateTime.of(2026, 4, 10, 0, 0) + ) + + val response = controller.updateAgentSettlementRatio(request) + + assertEquals(true, response.success) + Mockito.verify(service).updateAgentSettlementRatio(request) + } + + @Test + @DisplayName("관리자 컨트롤러는 정산 비율 목록 조회 파라미터를 서비스로 전달한다") + fun shouldForwardPageableToService() { + val responseBody = GetAgentSettlementRatioResponse( + totalCount = 1, + items = listOf( + GetAgentSettlementRatioItem( + memberId = 31L, + nickname = "agent-a", + settlementRatio = 15, + effectiveFrom = LocalDateTime.of(2026, 4, 9, 10, 0), + effectiveTo = null + ) + ) + ) + + Mockito.`when`(service.getAgentSettlementRatio(offset = 20L, limit = 20L)).thenReturn(responseBody) + + val response = controller.getAgentSettlementRatio(PageRequest.of(1, 20)) + + assertEquals(true, response.success) + assertEquals(1, response.data!!.totalCount) + assertEquals(15, response.data!!.items[0].settlementRatio) + assertEquals(LocalDateTime.of(2026, 4, 9, 10, 0), response.data!!.items[0].effectiveFrom) + assertEquals(null, response.data!!.items[0].effectiveTo) + Mockito.verify(service).getAgentSettlementRatio(offset = 20L, limit = 20L) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/ratio/AgentSettlementRatioServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/ratio/AgentSettlementRatioServiceTest.kt new file mode 100644 index 00000000..a3ce406f --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/admin/partner/agent/ratio/AgentSettlementRatioServiceTest.kt @@ -0,0 +1,368 @@ +package kr.co.vividnext.sodalive.admin.partner.agent.ratio + +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.ratio.AgentSettlementRatio +import kr.co.vividnext.sodalive.partner.agent.ratio.AgentSettlementRatioRepository +import kr.co.vividnext.sodalive.partner.agent.ratio.CreateAgentSettlementRatioRequest +import kr.co.vividnext.sodalive.partner.agent.ratio.GetAgentSettlementRatioItem +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.ArgumentCaptor +import org.mockito.InOrder +import org.mockito.Mockito +import org.springframework.dao.DataIntegrityViolationException +import java.time.LocalDateTime + +class AgentSettlementRatioServiceTest { + private lateinit var repository: AgentSettlementRatioRepository + private lateinit var memberRepository: MemberRepository + private lateinit var service: AdminAgentSettlementRatioService + + @BeforeEach + fun setup() { + repository = Mockito.mock(AgentSettlementRatioRepository::class.java) + memberRepository = Mockito.mock(MemberRepository::class.java) + service = AdminAgentSettlementRatioService( + repository = repository, + memberRepository = memberRepository + ) + } + + @Test + @DisplayName("관리자는 에이전트 정산 비율을 생성할 수 있다") + fun shouldCreateAgentSettlementRatio() { + val agent = Member(password = "password", nickname = "agent", role = MemberRole.AGENT) + agent.id = 31L + val effectiveFrom = LocalDateTime.of(2026, 4, 9, 10, 0) + val request = CreateAgentSettlementRatioRequest(memberId = 31L, settlementRatio = 15, effectiveFrom = effectiveFrom) + + Mockito.`when`(memberRepository.findByIdForUpdate(31L)).thenReturn(agent) + Mockito.`when`(repository.findFirstByMemberIdAndEffectiveToIsNull(31L)).thenReturn(null) + + service.createAgentSettlementRatio(request) + + val ratioCaptor = ArgumentCaptor.forClass(AgentSettlementRatio::class.java) + Mockito.verify(repository).saveAndFlush(ratioCaptor.capture()) + assertEquals(agent, ratioCaptor.value.member) + assertEquals(15, ratioCaptor.value.settlementRatio) + assertEquals(effectiveFrom, ratioCaptor.value.effectiveFrom) + assertEquals(null, ratioCaptor.value.effectiveTo) + } + + @Test + @DisplayName("기존 활성 비율이 있으면 생성 요청도 이전 row를 종료하고 새 이력 row를 추가한다") + fun shouldCloseCurrentRatioAndInsertNewHistoryWhenCreating() { + val agent = Member(password = "password", nickname = "agent", role = MemberRole.AGENT) + agent.id = 31L + val currentRatio = AgentSettlementRatio( + settlementRatio = 10, + effectiveFrom = LocalDateTime.of(2026, 4, 1, 0, 0) + ) + currentRatio.member = agent + + val newEffectiveFrom = LocalDateTime.of(2026, 4, 9, 10, 0) + val request = CreateAgentSettlementRatioRequest( + memberId = 31L, + settlementRatio = 15, + effectiveFrom = newEffectiveFrom + ) + + Mockito.`when`(memberRepository.findByIdForUpdate(31L)).thenReturn(agent) + Mockito.`when`(repository.findFirstByMemberIdAndEffectiveToIsNull(31L)).thenReturn(currentRatio) + + service.createAgentSettlementRatio(request) + + assertEquals(newEffectiveFrom, currentRatio.effectiveTo) + + val ratioCaptor = ArgumentCaptor.forClass(AgentSettlementRatio::class.java) + val inOrder: InOrder = Mockito.inOrder(repository) + inOrder.verify(repository).save(currentRatio) + inOrder.verify(repository).saveAndFlush(ratioCaptor.capture()) + + assertEquals(agent, ratioCaptor.value.member) + assertEquals(15, ratioCaptor.value.settlementRatio) + assertEquals(newEffectiveFrom, ratioCaptor.value.effectiveFrom) + assertEquals(null, ratioCaptor.value.effectiveTo) + } + + @Test + @DisplayName("기존 활성 비율 시작 시각보다 과거 effectiveFrom으로는 생성할 수 없다") + fun shouldThrowWhenCreatingRatioWithBackdatedEffectiveFrom() { + val agent = Member(password = "password", nickname = "agent", role = MemberRole.AGENT) + agent.id = 31L + val currentRatio = AgentSettlementRatio( + settlementRatio = 10, + effectiveFrom = LocalDateTime.of(2026, 4, 9, 10, 0) + ) + currentRatio.member = agent + val request = CreateAgentSettlementRatioRequest( + memberId = 31L, + settlementRatio = 15, + effectiveFrom = LocalDateTime.of(2026, 4, 9, 9, 59) + ) + + Mockito.`when`(memberRepository.findByIdForUpdate(31L)).thenReturn(agent) + Mockito.`when`(repository.findFirstByMemberIdAndEffectiveToIsNull(31L)).thenReturn(currentRatio) + + val exception = assertThrows(SodaException::class.java) { + service.createAgentSettlementRatio(request) + } + + assertEquals("partner.agent.ratio.invalid_effective_from", exception.messageKey) + Mockito.verify(repository, Mockito.never()).save(Mockito.any(AgentSettlementRatio::class.java)) + Mockito.verify(repository, Mockito.never()).saveAndFlush(Mockito.any(AgentSettlementRatio::class.java)) + } + + @Test + @DisplayName("settlementRatio가 0보다 작으면 정산 비율을 생성할 수 없다") + fun shouldThrowWhenCreatingRatioWithSettlementRatioBelowZero() { + val request = CreateAgentSettlementRatioRequest( + memberId = 31L, + settlementRatio = -1, + effectiveFrom = LocalDateTime.of(2026, 4, 9, 10, 0) + ) + + val exception = assertThrows(SodaException::class.java) { + service.createAgentSettlementRatio(request) + } + + assertEquals("common.error.invalid_request", exception.messageKey) + Mockito.verifyNoInteractions(memberRepository) + Mockito.verifyNoInteractions(repository) + } + + @Test + @DisplayName("기존 활성 비율 시작 시각과 같은 effectiveFrom으로는 수정할 수 없다") + fun shouldThrowWhenUpdatingRatioWithSameEffectiveFrom() { + val agent = Member(password = "password", nickname = "agent", role = MemberRole.AGENT) + agent.id = 31L + val existingRatio = AgentSettlementRatio( + settlementRatio = 10, + effectiveFrom = LocalDateTime.of(2026, 4, 9, 10, 0) + ) + existingRatio.member = agent + val request = CreateAgentSettlementRatioRequest( + memberId = 31L, + settlementRatio = 18, + effectiveFrom = LocalDateTime.of(2026, 4, 9, 10, 0) + ) + + Mockito.`when`(memberRepository.findByIdForUpdate(31L)).thenReturn(agent) + Mockito.`when`(repository.findFirstByMemberIdAndEffectiveToIsNull(31L)).thenReturn(existingRatio) + + val exception = assertThrows(SodaException::class.java) { + service.updateAgentSettlementRatio(request) + } + + assertEquals("partner.agent.ratio.invalid_effective_from", exception.messageKey) + Mockito.verify(repository, Mockito.never()).save(Mockito.any(AgentSettlementRatio::class.java)) + Mockito.verify(repository, Mockito.never()).saveAndFlush(Mockito.any(AgentSettlementRatio::class.java)) + } + + @Test + @DisplayName("settlementRatio가 100보다 크면 정산 비율을 수정할 수 없다") + fun shouldThrowWhenUpdatingRatioWithSettlementRatioAboveHundred() { + val request = CreateAgentSettlementRatioRequest( + memberId = 31L, + settlementRatio = 101, + effectiveFrom = LocalDateTime.of(2026, 4, 9, 10, 0) + ) + + val exception = assertThrows(SodaException::class.java) { + service.updateAgentSettlementRatio(request) + } + + assertEquals("common.error.invalid_request", exception.messageKey) + Mockito.verifyNoInteractions(memberRepository) + Mockito.verifyNoInteractions(repository) + } + + @Test + @DisplayName("활성 비율이 없어도 기존 이력 구간과 겹치는 effectiveFrom으로는 생성할 수 없다") + fun shouldThrowWhenCreatingRatioOverlappingClosedHistory() { + val agent = Member(password = "password", nickname = "agent", role = MemberRole.AGENT) + agent.id = 31L + val historicalRatio = AgentSettlementRatio( + settlementRatio = 10, + effectiveFrom = LocalDateTime.of(2026, 4, 1, 0, 0) + ) + historicalRatio.member = agent + historicalRatio.effectiveTo = LocalDateTime.of(2026, 4, 10, 0, 0) + val request = CreateAgentSettlementRatioRequest( + memberId = 31L, + settlementRatio = 18, + effectiveFrom = LocalDateTime.of(2026, 4, 5, 0, 0) + ) + + Mockito.`when`(memberRepository.findByIdForUpdate(31L)).thenReturn(agent) + Mockito.`when`(repository.findFirstByMemberIdAndEffectiveToIsNull(31L)).thenReturn(null) + Mockito.`when`(repository.findAllByMemberIdOrderByEffectiveFromAsc(31L)).thenReturn(listOf(historicalRatio)) + + val exception = assertThrows(SodaException::class.java) { + service.createAgentSettlementRatio(request) + } + + assertEquals("partner.agent.ratio.invalid_effective_from", exception.messageKey) + Mockito.verify(repository, Mockito.never()).save(Mockito.any(AgentSettlementRatio::class.java)) + Mockito.verify(repository, Mockito.never()).saveAndFlush(Mockito.any(AgentSettlementRatio::class.java)) + } + + @Test + @DisplayName("동시 요청으로 비율 unique 제약이 충돌하면 잘못된 effectiveFrom 예외로 변환한다") + fun shouldThrowWhenConcurrentInsertViolatesUniqueConstraint() { + val agent = Member(password = "password", nickname = "agent", role = MemberRole.AGENT) + agent.id = 31L + val request = CreateAgentSettlementRatioRequest( + memberId = 31L, + settlementRatio = 15, + effectiveFrom = LocalDateTime.of(2026, 4, 9, 10, 0) + ) + val currentRatio = AgentSettlementRatio( + settlementRatio = 12, + effectiveFrom = LocalDateTime.of(2026, 4, 9, 10, 0) + ) + currentRatio.member = agent + + Mockito.`when`(memberRepository.findByIdForUpdate(31L)).thenReturn(agent) + Mockito.`when`(repository.findFirstByMemberIdAndEffectiveToIsNull(31L)).thenReturn(null, currentRatio) + Mockito.`when`(repository.saveAndFlush(Mockito.any(AgentSettlementRatio::class.java))) + .thenThrow(DataIntegrityViolationException("duplicate")) + + val exception = assertThrows(SodaException::class.java) { + service.createAgentSettlementRatio(request) + } + + assertEquals("partner.agent.ratio.invalid_effective_from", exception.messageKey) + } + + @Test + @DisplayName("에이전트 회원이 없으면 정산 비율을 생성할 수 없다") + fun shouldThrowWhenAgentDoesNotExist() { + val request = CreateAgentSettlementRatioRequest( + memberId = 31L, + settlementRatio = 15, + effectiveFrom = LocalDateTime.of(2026, 4, 9, 10, 0) + ) + + Mockito.`when`(memberRepository.findByIdForUpdate(31L)).thenReturn(null) + + val exception = assertThrows(SodaException::class.java) { + service.createAgentSettlementRatio(request) + } + + assertEquals("partner.agent.ratio.agent_not_found", exception.messageKey) + Mockito.verify(memberRepository).findByIdForUpdate(31L) + Mockito.verifyNoInteractions(repository) + } + + @Test + @DisplayName("에이전트가 아닌 회원에게는 정산 비율을 생성할 수 없다") + fun shouldThrowWhenMemberIsNotAgent() { + val creator = Member(password = "password", nickname = "creator", role = MemberRole.CREATOR) + creator.id = 31L + val request = CreateAgentSettlementRatioRequest( + memberId = 31L, + settlementRatio = 15, + effectiveFrom = LocalDateTime.of(2026, 4, 9, 10, 0) + ) + + Mockito.`when`(memberRepository.findByIdForUpdate(31L)).thenReturn(creator) + + val exception = assertThrows(SodaException::class.java) { + service.createAgentSettlementRatio(request) + } + + assertEquals("partner.agent.ratio.invalid_agent", exception.messageKey) + Mockito.verify(memberRepository).findByIdForUpdate(31L) + Mockito.verifyNoInteractions(repository) + } + + @Test + @DisplayName("관리자는 기존 에이전트 정산 비율을 수정할 수 있다") + fun shouldUpdateAgentSettlementRatio() { + val agent = Member(password = "password", nickname = "agent", role = MemberRole.AGENT) + agent.id = 31L + val existingRatio = AgentSettlementRatio( + settlementRatio = 10, + effectiveFrom = LocalDateTime.of(2026, 4, 1, 0, 0) + ) + existingRatio.member = agent + val newEffectiveFrom = LocalDateTime.of(2026, 4, 9, 10, 0) + val request = CreateAgentSettlementRatioRequest( + memberId = 31L, + settlementRatio = 18, + effectiveFrom = newEffectiveFrom + ) + + Mockito.`when`(memberRepository.findByIdForUpdate(31L)).thenReturn(agent) + Mockito.`when`(repository.findFirstByMemberIdAndEffectiveToIsNull(31L)).thenReturn(existingRatio) + + service.updateAgentSettlementRatio(request) + + assertEquals(newEffectiveFrom, existingRatio.effectiveTo) + + val ratioCaptor = ArgumentCaptor.forClass(AgentSettlementRatio::class.java) + val inOrder: InOrder = Mockito.inOrder(repository) + inOrder.verify(repository).save(existingRatio) + inOrder.verify(repository).saveAndFlush(ratioCaptor.capture()) + + assertEquals(agent, ratioCaptor.value.member) + assertEquals(18, ratioCaptor.value.settlementRatio) + assertEquals(newEffectiveFrom, ratioCaptor.value.effectiveFrom) + assertEquals(null, ratioCaptor.value.effectiveTo) + } + + @Test + @DisplayName("기존 정산 비율이 없으면 수정할 수 없다") + fun shouldThrowWhenSettlementRatioDoesNotExist() { + val agent = Member(password = "password", nickname = "agent", role = MemberRole.AGENT) + agent.id = 31L + val request = CreateAgentSettlementRatioRequest( + memberId = 31L, + settlementRatio = 18, + effectiveFrom = LocalDateTime.of(2026, 4, 9, 10, 0) + ) + + Mockito.`when`(memberRepository.findByIdForUpdate(31L)).thenReturn(agent) + Mockito.`when`(repository.findFirstByMemberIdAndEffectiveToIsNull(31L)).thenReturn(null) + + val exception = assertThrows(SodaException::class.java) { + service.updateAgentSettlementRatio(request) + } + + assertEquals("partner.agent.ratio.not_found", exception.messageKey) + Mockito.verify(repository).findFirstByMemberIdAndEffectiveToIsNull(31L) + Mockito.verify(repository, Mockito.never()).save(Mockito.any(AgentSettlementRatio::class.java)) + } + + @Test + @DisplayName("관리자는 에이전트 정산 비율 목록을 조회할 수 있다") + fun shouldGetAgentSettlementRatioList() { + val items = listOf( + GetAgentSettlementRatioItem( + memberId = 31L, + nickname = "agent-a", + settlementRatio = 15, + effectiveFrom = LocalDateTime.of(2026, 4, 9, 10, 0), + effectiveTo = null + ) + ) + + Mockito.`when`(repository.getAgentSettlementRatioTotalCount()).thenReturn(1) + Mockito.`when`(repository.getAgentSettlementRatio(offset = 20L, limit = 20L)).thenReturn(items) + + val response = service.getAgentSettlementRatio(offset = 20L, limit = 20L) + + assertEquals(1, response.totalCount) + assertEquals(1, response.items.size) + assertEquals(15, response.items[0].settlementRatio) + assertEquals(LocalDateTime.of(2026, 4, 9, 10, 0), response.items[0].effectiveFrom) + assertEquals(null, response.items[0].effectiveTo) + } +}