diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftDeliveryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftDeliveryRepository.kt index 63e2b543..2a4c3487 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftDeliveryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftDeliveryRepository.kt @@ -4,4 +4,6 @@ import org.springframework.data.jpa.repository.JpaRepository interface GiftDeliveryRepository : JpaRepository { fun findByGiftId(giftId: Long): GiftDelivery? + + fun findByGiftIdIn(giftIds: Collection): List } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftRepository.kt index 1c055939..d689862b 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftRepository.kt @@ -26,6 +26,41 @@ interface GiftRepository : JpaRepository { pageable: Pageable ): Page + @Query( + value = """ + select g + from Gift g + join Member sender on sender.id = g.senderMemberId + join Member recipient on recipient.id = g.recipientMemberId + where (:status is null or g.status = :status) + and (:applicationNo is null or g.applicationNo like concat('%', :applicationNo, '%')) + and ( + :nickname is null + or sender.nickname like concat('%', :nickname, '%') + or recipient.nickname like concat('%', :nickname, '%') + ) + """, + countQuery = """ + select count(g) + from Gift g + join Member sender on sender.id = g.senderMemberId + join Member recipient on recipient.id = g.recipientMemberId + where (:status is null or g.status = :status) + and (:applicationNo is null or g.applicationNo like concat('%', :applicationNo, '%')) + and ( + :nickname is null + or sender.nickname like concat('%', :nickname, '%') + or recipient.nickname like concat('%', :nickname, '%') + ) + """ + ) + fun findAdminGifts( + @Param("status") status: GiftStatus?, + @Param("applicationNo") applicationNo: String?, + @Param("nickname") nickname: String?, + pageable: Pageable + ): Page + @Query( """ select g diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminQueryService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminQueryService.kt new file mode 100644 index 00000000..13cb9d06 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminQueryService.kt @@ -0,0 +1,205 @@ +package kr.co.vividnext.sodalive.v2.gift.application + +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.v2.gift.adapter.out.persistence.Gift +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategory +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategoryRepository +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDelivery +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDeliveryRepository +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftRepository +import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Sort +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +data class AdminGiftListResult( + val totalCount: Long, + val items: List, + val page: Int, + val size: Int, + val hasNext: Boolean +) + +data class AdminGiftListItemResult( + val applicationNo: String, + val senderNickname: String, + val recipientNickname: String, + val sizeCode: String, + val sizeName: String, + val categoryName: String, + val classificationNumber: String, + val courierCompanyName: String?, + val trackingNumber: String?, + val status: GiftStatus, + val statusName: String, + val availableActions: List +) + +data class AdminGiftDetailResult( + val applicationNo: String, + val senderInfo: AdminGiftMemberInfoResult, + val recipientInfo: AdminGiftMemberInfoResult, + val productInfo: AdminGiftProductInfoResult, + val inboundDeliveryInfo: AdminGiftInboundDeliveryInfoResult, + val status: GiftStatus, + val statusName: String, + val availableActions: List +) + +data class AdminGiftMemberInfoResult( + val nickname: String, + val name: String, + val phoneNumber: String, + val address: String +) + +data class AdminGiftProductInfoResult( + val sizeCode: String, + val sizeName: String, + val categoryName: String, + val classificationNumber: String +) + +data class AdminGiftInboundDeliveryInfoResult( + val courierCompanyName: String?, + val trackingNumber: String? +) + +@Service +class GiftAdminQueryService( + private val giftRepository: GiftRepository, + private val deliveryRepository: GiftDeliveryRepository, + private val categoryRepository: GiftCategoryRepository, + private val memberRepository: MemberRepository +) { + @Transactional(readOnly = true) + fun getGifts( + status: GiftStatus?, + applicationNo: String?, + nickname: String?, + page: Int, + size: Int + ): AdminGiftListResult { + val normalizedPage = page.coerceAtLeast(0) + val normalizedSize = if (size <= 0) DEFAULT_PAGE_SIZE else size + val gifts = giftRepository.findAdminGifts( + status = status, + applicationNo = applicationNo?.takeIf { it.isNotBlank() }, + nickname = nickname?.takeIf { it.isNotBlank() }, + pageable = PageRequest.of(normalizedPage, normalizedSize, Sort.by(Sort.Direction.DESC, "createdAt", "id")) + ) + val context = buildContext(gifts.content) + + return AdminGiftListResult( + totalCount = gifts.totalElements, + items = gifts.content.map { gift -> gift.toListItem(context) }, + page = normalizedPage, + size = normalizedSize, + hasNext = gifts.hasNext() + ) + } + + @Transactional(readOnly = true) + fun getGiftDetail(applicationNo: String): AdminGiftDetailResult { + val gift = giftRepository.findByApplicationNo(applicationNo) ?: throw invalidRequest() + val context = buildContext(listOf(gift)) + val delivery = context.delivery(gift) + val sender = context.sender(gift) + val recipient = context.recipient(gift) + val category = context.category(gift) + + return AdminGiftDetailResult( + applicationNo = gift.applicationNo, + senderInfo = AdminGiftMemberInfoResult( + nickname = sender.nickname, + name = delivery.senderName, + phoneNumber = delivery.senderPhoneNumber, + address = formatAddress(delivery.senderZipCode, delivery.senderAddress, delivery.senderAddressDetail) + ), + recipientInfo = AdminGiftMemberInfoResult( + nickname = recipient.nickname, + name = delivery.recipientName.orEmpty(), + phoneNumber = delivery.recipientPhoneNumber.orEmpty(), + address = delivery.recipientAddressResult() + ), + productInfo = AdminGiftProductInfoResult( + sizeCode = gift.sizeCode.name, + sizeName = gift.sizeCode.displayName, + categoryName = gift.categoryNameSnapshot, + classificationNumber = category.classificationNumber + ), + inboundDeliveryInfo = AdminGiftInboundDeliveryInfoResult( + courierCompanyName = delivery.courierCompanyName, + trackingNumber = delivery.trackingNumber + ), + status = gift.status, + statusName = gift.status.displayName, + availableActions = gift.status.availableActions() + ) + } + + private fun Gift.toListItem(context: GiftAdminQueryContext) = AdminGiftListItemResult( + applicationNo = applicationNo, + senderNickname = context.sender(this).nickname, + recipientNickname = context.recipient(this).nickname, + sizeCode = sizeCode.name, + sizeName = sizeCode.displayName, + categoryName = categoryNameSnapshot, + classificationNumber = context.category(this).classificationNumber, + courierCompanyName = context.delivery(this).courierCompanyName, + trackingNumber = context.delivery(this).trackingNumber, + status = status, + statusName = status.displayName, + availableActions = status.availableActions() + ) + + private fun GiftStatus.availableActions() = when (this) { + GiftStatus.TRACKING_REGISTERED -> listOf("ARRIVE_MAILBOX", "MARK_UNDELIVERABLE") + GiftStatus.ARRIVED_AT_MAILBOX -> listOf("COMPLETE_INSPECTION", "MARK_UNDELIVERABLE") + GiftStatus.INSPECTION_COMPLETED -> listOf("COMPLETE_DELIVERY", "MARK_UNDELIVERABLE") + GiftStatus.RECEIVED, GiftStatus.DELIVERED, GiftStatus.UNDELIVERABLE, GiftStatus.CANCELED -> emptyList() + } + + private fun GiftDelivery.recipientAddressResult(): String { + val zipCode = recipientZipCode ?: return "" + val address = recipientAddress ?: return "" + return formatAddress(zipCode, address, recipientAddressDetail) + } + + private fun formatAddress(zipCode: String, address: String, detail: String?) = + "($zipCode) $address" + if (detail.isNullOrBlank()) "" else ", $detail" + + private fun invalidRequest() = SodaException(messageKey = "common.error.invalid_request") + + private fun buildContext(gifts: List): GiftAdminQueryContext { + val giftIds = gifts.mapNotNull { it.id } + val memberIds = gifts.flatMap { listOf(it.senderMemberId, it.recipientMemberId) }.toSet() + val categoryIds = gifts.map { it.categoryId }.toSet() + return GiftAdminQueryContext( + deliveriesByGiftId = deliveryRepository.findByGiftIdIn(giftIds).associateBy { it.giftId }, + membersById = memberRepository.findAllById(memberIds).associateBy { it.id ?: throw invalidRequest() }, + categoriesById = categoryRepository.findAllById(categoryIds).associateBy { it.id ?: throw invalidRequest() } + ) + } + + private inner class GiftAdminQueryContext( + private val deliveriesByGiftId: Map, + private val membersById: Map, + private val categoriesById: Map + ) { + fun delivery(gift: Gift) = deliveriesByGiftId[gift.id] ?: throw invalidRequest() + + fun sender(gift: Gift) = membersById[gift.senderMemberId] ?: throw invalidRequest() + + fun recipient(gift: Gift) = membersById[gift.recipientMemberId] ?: throw invalidRequest() + + fun category(gift: Gift) = categoriesById[gift.categoryId] ?: throw invalidRequest() + } + + private companion object { + const val DEFAULT_PAGE_SIZE = 20 + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminQueryServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminQueryServiceTest.kt new file mode 100644 index 00000000..a2c50924 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminQueryServiceTest.kt @@ -0,0 +1,228 @@ +package kr.co.vividnext.sodalive.v2.gift.application + +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.configs.QueryDslConfig +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRepository +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.Gift +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategory +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategoryRepository +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDelivery +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDeliveryRepository +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftRepository +import kr.co.vividnext.sodalive.v2.gift.domain.GiftSize +import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +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 + +@DataJpaTest( + properties = [ + "spring.cache.type=none", + "spring.datasource.url=jdbc:h2:mem:gift-admin-query;MODE=MySQL;NON_KEYWORDS=VALUE" + ] +) +@Import(QueryDslConfig::class) +@DisplayName("관리자 선물함 조회 서비스 테스트") +class GiftAdminQueryServiceTest @Autowired constructor( + private val giftRepository: GiftRepository, + private val deliveryRepository: GiftDeliveryRepository, + private val categoryRepository: GiftCategoryRepository, + private val memberRepository: MemberRepository +) { + @Test + @DisplayName("관리자는 선물함 목록을 필터링하고 가능한 액션을 조회한다") + fun shouldListAdminGiftsWithFiltersAndAvailableActions() { + val sender = saveMember("fan-alpha") + val recipient = saveMember("creator-alpha") + val otherSender = saveMember("fan-beta") + val otherRecipient = saveMember("creator-beta") + val category = saveCategory() + saveGift( + applicationNo = "A-1002609300001", + sender = sender, + recipient = recipient, + category = category, + status = GiftStatus.TRACKING_REGISTERED, + withTracking = true + ) + saveGift( + applicationNo = "A-1002609300002", + sender = otherSender, + recipient = otherRecipient, + category = category, + status = GiftStatus.RECEIVED + ) + + val result = service().getGifts( + status = GiftStatus.TRACKING_REGISTERED, + applicationNo = "0001", + nickname = "alpha", + page = 0, + size = 20 + ) + + assertEquals(1L, result.totalCount) + assertEquals(1, result.items.size) + assertEquals(0, result.page) + assertEquals(20, result.size) + assertEquals(false, result.hasNext) + val item = result.items.single() + assertEquals("A-1002609300001", item.applicationNo) + assertEquals("fan-alpha", item.senderNickname) + assertEquals("creator-alpha", item.recipientNickname) + assertEquals("SMALL", item.sizeCode) + assertEquals("소형", item.sizeName) + assertEquals("인형", item.categoryName) + assertEquals("100", item.classificationNumber) + assertEquals("CJ대한통운", item.courierCompanyName) + assertEquals("1234567890", item.trackingNumber) + assertEquals(GiftStatus.TRACKING_REGISTERED, item.status) + assertEquals("발송 확인", item.statusName) + assertEquals(listOf("ARRIVE_MAILBOX", "MARK_UNDELIVERABLE"), item.availableActions) + } + + @Test + @DisplayName("관리자는 선물 상세에서 개인정보와 상품 정보를 조회하고 미입력 수취인 정보는 빈 문자열로 받는다") + fun shouldGetAdminGiftDetailWithPersonalInformation() { + val sender = saveMember("fan-alpha") + val recipient = saveMember("creator-alpha") + val category = saveCategory() + saveGift( + applicationNo = "A-1002609300001", + sender = sender, + recipient = recipient, + category = category, + status = GiftStatus.ARRIVED_AT_MAILBOX, + withTracking = true + ) + + val result = service().getGiftDetail("A-1002609300001") + + assertEquals("A-1002609300001", result.applicationNo) + assertEquals("fan-alpha", result.senderInfo.nickname) + assertEquals("홍길동", result.senderInfo.name) + assertEquals("01012345678", result.senderInfo.phoneNumber) + assertEquals("(06234) 서울시 강남구, 101동 1001호", result.senderInfo.address) + assertEquals("creator-alpha", result.recipientInfo.nickname) + assertEquals("", result.recipientInfo.name) + assertEquals("", result.recipientInfo.phoneNumber) + assertEquals("", result.recipientInfo.address) + assertEquals("SMALL", result.productInfo.sizeCode) + assertEquals("소형", result.productInfo.sizeName) + assertEquals("인형", result.productInfo.categoryName) + assertEquals("100", result.productInfo.classificationNumber) + assertEquals("CJ대한통운", result.inboundDeliveryInfo.courierCompanyName) + assertEquals("1234567890", result.inboundDeliveryInfo.trackingNumber) + assertEquals(GiftStatus.ARRIVED_AT_MAILBOX, result.status) + assertEquals("사서함 도착", result.statusName) + assertEquals(listOf("COMPLETE_INSPECTION", "MARK_UNDELIVERABLE"), result.availableActions) + } + + @Test + @DisplayName("관리자 선물 상세는 상태별 가능한 액션을 반환하고 없는 신청번호를 거부한다") + fun shouldReturnAvailableActionsByStatusAndRejectUnknownApplicationNo() { + val sender = saveMember("fan-alpha") + val recipient = saveMember("creator-alpha") + val category = saveCategory() + GiftStatus.values().forEachIndexed { index, status -> + saveGift("A-10026093010$index", sender, recipient, category, status) + } + + val service = service() + + assertEquals(emptyList(), service.getGiftDetail("A-100260930100").availableActions) + assertEquals( + listOf("ARRIVE_MAILBOX", "MARK_UNDELIVERABLE"), + service.getGiftDetail("A-100260930101").availableActions + ) + assertEquals( + listOf("COMPLETE_INSPECTION", "MARK_UNDELIVERABLE"), + service.getGiftDetail("A-100260930102").availableActions + ) + assertEquals( + listOf("COMPLETE_DELIVERY", "MARK_UNDELIVERABLE"), + service.getGiftDetail("A-100260930103").availableActions + ) + assertEquals(emptyList(), service.getGiftDetail("A-100260930104").availableActions) + assertEquals(emptyList(), service.getGiftDetail("A-100260930105").availableActions) + assertEquals(emptyList(), service.getGiftDetail("A-100260930106").availableActions) + val exception = assertThrows(SodaException::class.java) { + service.getGiftDetail("UNKNOWN") + } + assertEquals("common.error.invalid_request", exception.messageKey) + } + + private fun service() = GiftAdminQueryService( + giftRepository = giftRepository, + deliveryRepository = deliveryRepository, + categoryRepository = categoryRepository, + memberRepository = memberRepository + ) + + private fun saveMember(nickname: String) = memberRepository.save(Member(password = "password", nickname = nickname)) + + private fun saveCategory() = categoryRepository.save( + GiftCategory( + classificationNumber = "100", + categoryCode = "DOLL", + name = "인형", + receiptCode = "A", + representativeItem = "피규어" + ) + ) + + private fun saveGift( + applicationNo: String, + sender: Member, + recipient: Member, + category: GiftCategory, + status: GiftStatus, + withTracking: Boolean = false + ): Gift { + val gift = giftRepository.save( + Gift( + applicationNo = applicationNo, + senderMemberId = sender.id!!, + recipientMemberId = recipient.id!!, + status = status, + sizeCode = GiftSize.SMALL, + categoryId = category.id!!, + categoryNameSnapshot = category.name, + salePriceCan = 80, + canUsageId = 10L, + senderTermsAgreed = true, + senderPrivacyAgreed = true, + senderTermsAgreedAt = NOW, + senderPrivacyAgreedAt = NOW + ) + ) + deliveryRepository.save( + GiftDelivery( + giftId = gift.id!!, + senderName = "홍길동", + senderPhoneNumber = "01012345678", + senderZipCode = "06234", + senderAddress = "서울시 강남구", + senderAddressDetail = "101동 1001호", + trackingDeadlineAt = NOW.plusDays(3) + ).apply { + if (withTracking) { + courierCompanyName = "CJ대한통운" + trackingNumber = "1234567890" + trackingRegisteredAt = NOW.plusHours(1) + } + } + ) + return gift + } + + private companion object { + val NOW: LocalDateTime = LocalDateTime.of(2026, 9, 30, 12, 0) + } +}