feat(agent-settlement): 에이전트 정산 스냅샷 관리 기능을 추가한다

This commit is contained in:
2026-04-10 02:23:45 +09:00
parent d0be8ec2db
commit 9e4cd1bb6e
10 changed files with 840 additions and 0 deletions

View File

@@ -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!!))
}
}

View File

@@ -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<AgentSettlementSnapshot>,
val sourceDetailsByCreatorId: Map<Long, List<SourceDetailDraft>>
)
@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<GetAgentCreatorSettlementSummaryQueryData>
): 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<GetAgentChannelDonationSettlementByCreatorQueryData>
): 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<AgentSettlementSnapshotSourceDetail> {
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
)
}

View File

@@ -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
}

View File

@@ -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<AgentSettlementSnapshot, Long> {
fun existsByPeriodStartAndPeriodEndAndSettlementTypeAndAgentId(
periodStart: LocalDateTime,
periodEnd: LocalDateTime,
settlementType: AgentSettlementSnapshotType,
agentId: Long
): Boolean
fun findAllByPeriodStartAndPeriodEndAndSettlementTypeAndAgentIdOrderByCreatorIdDesc(
periodStart: LocalDateTime,
periodEnd: LocalDateTime,
settlementType: AgentSettlementSnapshotType,
agentId: Long
): List<AgentSettlementSnapshot>
}

View File

@@ -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<AgentSettlementSnapshot>.toSettlementByCreatorItems(): List<GetAgentSettlementByCreatorItem> {
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<AgentSettlementSnapshot>.toChannelDonationSettlementByCreatorItems():
List<GetAgentChannelDonationSettlementByCreatorItem> {
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
)
}
}

View File

@@ -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()

View File

@@ -0,0 +1,5 @@
package kr.co.vividnext.sodalive.partner.agent.settlement.snapshot
import org.springframework.data.jpa.repository.JpaRepository
interface AgentSettlementSnapshotSourceDetailRepository : JpaRepository<AgentSettlementSnapshotSourceDetail, Long>

View File

@@ -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<LocalDateTime, LocalDateTime> {
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
)