diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminService.kt new file mode 100644 index 00000000..c61bcc2d --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminService.kt @@ -0,0 +1,210 @@ +package kr.co.vividnext.sodalive.v2.gift.application + +import kr.co.vividnext.sodalive.common.SodaException +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.GiftDeliveryRepository +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftRepository +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePrice +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePriceRepository +import kr.co.vividnext.sodalive.v2.gift.domain.GiftSize +import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime + +data class GiftCategoryCommand( + val categoryCode: String, + val name: String, + val requiresDamageWaiver: Boolean, + val isActive: Boolean +) + +data class GiftCategoryResult( + val categoryId: Long, + val categoryCode: String, + val name: String, + val requiresDamageWaiver: Boolean, + val isActive: Boolean +) + +data class GiftSizePriceCommand( + val basePriceCan: Int, + val salePriceCan: Int, + val isActive: Boolean +) + +data class GiftSizePriceResult( + val sizeCode: GiftSize, + val sizeName: String, + val basePriceCan: Int, + val salePriceCan: Int, + val isActive: Boolean +) + +data class GiftOperationStatusResult( + val applicationNo: String, + val status: GiftStatus, + val statusName: String, + val occurredAt: LocalDateTime +) + +@Service +class GiftAdminService( + private val categoryRepository: GiftCategoryRepository, + private val sizePriceRepository: GiftSizePriceRepository, + private val giftRepository: GiftRepository, + private val giftDeliveryRepository: GiftDeliveryRepository, + private val giftPushService: GiftPushService +) { + @Transactional(readOnly = true) + fun getCategories(): List = categoryRepository.findAll().map { it.toResult() } + + @Transactional + fun createCategory(command: GiftCategoryCommand): GiftCategoryResult { + validateCategory(command) + return categoryRepository.save( + GiftCategory( + categoryCode = command.categoryCode, + name = command.name, + requiresDamageWaiver = command.requiresDamageWaiver, + isActive = command.isActive + ) + ).toResult() + } + + @Transactional + fun updateCategory(categoryId: Long, command: GiftCategoryCommand): GiftCategoryResult { + validateCategory(command) + val category = categoryRepository.findByIdOrNull(categoryId) ?: throw invalidRequest() + category.name = command.name + category.requiresDamageWaiver = command.requiresDamageWaiver + category.isActive = command.isActive + return category.toResult() + } + + @Transactional + fun deleteCategory(categoryId: Long): GiftCategoryResult { + val category = categoryRepository.findByIdOrNull(categoryId) ?: throw invalidRequest() + category.isActive = false + return category.toResult() + } + + @Transactional(readOnly = true) + fun getSizePrices(): List = sizePriceRepository.findAll().map { it.toResult() } + + @Transactional + fun updateSizePrice(sizeCode: GiftSize, command: GiftSizePriceCommand): GiftSizePriceResult { + validateSizePrice(command) + val sizePrice = sizePriceRepository.findBySizeCode(sizeCode) + ?: GiftSizePrice(sizeCode = sizeCode, basePriceCan = command.basePriceCan, salePriceCan = command.salePriceCan) + sizePrice.basePriceCan = command.basePriceCan + sizePrice.salePriceCan = command.salePriceCan + sizePrice.isActive = command.isActive + return sizePriceRepository.save(sizePrice).toResult() + } + + @Transactional + fun arriveMailbox(applicationNo: String, now: LocalDateTime = LocalDateTime.now()): GiftOperationStatusResult { + val gift = giftRepository.findByApplicationNo(applicationNo) ?: throw invalidRequest() + if (gift.status != GiftStatus.TRACKING_REGISTERED) throw invalidRequest() + val delivery = giftDeliveryRepository.findByGiftId(requireNotNull(gift.id)) ?: throw invalidRequest() + + gift.status = GiftStatus.ARRIVED_AT_MAILBOX + delivery.arrivedAtMailboxAt = now + giftPushService.sendArrivedAtMailboxToSender(gift.applicationNo, gift.senderMemberId) + giftPushService.sendArrivedAtMailboxToRecipient(gift.applicationNo, gift.recipientMemberId) + + return GiftOperationStatusResult(gift.applicationNo, gift.status, gift.status.displayName, now) + } + + @Transactional + fun completeInspection(applicationNo: String, now: LocalDateTime = LocalDateTime.now()): GiftOperationStatusResult { + val gift = giftRepository.findByApplicationNo(applicationNo) ?: throw invalidRequest() + if (gift.status != GiftStatus.ARRIVED_AT_MAILBOX) throw invalidRequest() + val delivery = giftDeliveryRepository.findByGiftId(requireNotNull(gift.id)) ?: throw invalidRequest() + + gift.status = GiftStatus.INSPECTION_COMPLETED + delivery.inspectionCompletedAt = now + giftPushService.sendInspectionCompleted(gift.applicationNo, gift.recipientMemberId) + + return GiftOperationStatusResult(gift.applicationNo, gift.status, gift.status.displayName, now) + } + + @Transactional + fun markUndeliverable( + applicationNo: String, + reason: String, + now: LocalDateTime = LocalDateTime.now() + ): GiftOperationStatusResult { + val gift = giftRepository.findByApplicationNo(applicationNo) ?: throw invalidRequest() + if (gift.status !in UNDELIVERABLE_ALLOWED_STATUSES || reason.isBlank() || reason.length > 255) { + throw invalidRequest() + } + val delivery = giftDeliveryRepository.findByGiftId(requireNotNull(gift.id)) ?: throw invalidRequest() + + gift.status = GiftStatus.UNDELIVERABLE + delivery.undeliverableAt = now + delivery.undeliverableReason = reason + giftPushService.sendUndeliverableToSender(gift.applicationNo, gift.senderMemberId) + giftPushService.sendUndeliverableToRecipient(gift.applicationNo, gift.recipientMemberId) + + return GiftOperationStatusResult(gift.applicationNo, gift.status, gift.status.displayName, now) + } + + @Transactional + fun completeDelivery(applicationNo: String, now: LocalDateTime = LocalDateTime.now()): GiftOperationStatusResult { + val gift = giftRepository.findByApplicationNo(applicationNo) ?: throw invalidRequest() + if (gift.status != GiftStatus.INSPECTION_COMPLETED) throw invalidRequest() + val delivery = giftDeliveryRepository.findByGiftId(requireNotNull(gift.id)) ?: throw invalidRequest() + + gift.status = GiftStatus.DELIVERED + delivery.deliveredAt = now + giftPushService.sendDeliveredToSender(gift.applicationNo, gift.senderMemberId) + giftPushService.sendDeliveredToRecipient(gift.applicationNo, gift.recipientMemberId) + + return GiftOperationStatusResult(gift.applicationNo, gift.status, gift.status.displayName, now) + } + + private fun validateCategory(command: GiftCategoryCommand) { + if ( + command.categoryCode.isBlank() || + command.categoryCode.length > 50 || + command.name.isBlank() || + command.name.length > 50 + ) { + throw invalidRequest() + } + } + + private fun validateSizePrice(command: GiftSizePriceCommand) { + if (command.salePriceCan <= 0 || command.salePriceCan > command.basePriceCan) throw invalidRequest() + } + + private fun GiftCategory.toResult() = GiftCategoryResult( + categoryId = id ?: throw invalidRequest(), + categoryCode = categoryCode, + name = name, + requiresDamageWaiver = requiresDamageWaiver, + isActive = isActive + ) + + private fun GiftSizePrice.toResult() = GiftSizePriceResult( + sizeCode = sizeCode, + sizeName = sizeCode.displayName, + basePriceCan = basePriceCan, + salePriceCan = salePriceCan, + isActive = isActive + ) + + private fun invalidRequest() = SodaException(messageKey = "common.error.invalid_request") + + private companion object { + val UNDELIVERABLE_ALLOWED_STATUSES = setOf( + GiftStatus.TRACKING_REGISTERED, + GiftStatus.ARRIVED_AT_MAILBOX, + GiftStatus.INSPECTION_COMPLETED + ) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminServiceTest.kt new file mode 100644 index 00000000..4b3be603 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminServiceTest.kt @@ -0,0 +1,223 @@ +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.v2.gift.adapter.out.persistence.Gift +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.adapter.out.persistence.GiftSizePriceRepository +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.assertFalse +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.Mockito +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:testdb;MODE=MySQL;NON_KEYWORDS=VALUE" + ] +) +@Import(QueryDslConfig::class) +@DisplayName("선물 관리자 서비스 테스트") +class GiftAdminServiceTest @Autowired constructor( + private val categoryRepository: GiftCategoryRepository, + private val sizePriceRepository: GiftSizePriceRepository, + private val giftRepository: GiftRepository, + private val giftDeliveryRepository: GiftDeliveryRepository +) { + private val giftPushService = Mockito.mock(GiftPushService::class.java) + private val service = GiftAdminService( + categoryRepository, + sizePriceRepository, + giftRepository, + giftDeliveryRepository, + giftPushService + ) + + @Test + @DisplayName("카테고리를 등록, 수정, 논리삭제한다") + fun shouldCreateUpdateAndDeactivateCategory() { + val created = service.createCategory( + GiftCategoryCommand(categoryCode = "DOLL", name = "인형", requiresDamageWaiver = true, isActive = true) + ) + val updated = service.updateCategory( + created.categoryId, + GiftCategoryCommand(categoryCode = "DOLL", name = "큰 인형", requiresDamageWaiver = false, isActive = true) + ) + service.deleteCategory(created.categoryId) + + val saved = categoryRepository.findById(created.categoryId).orElseThrow() + assertEquals("큰 인형", updated.name) + assertFalse(updated.requiresDamageWaiver) + assertFalse(saved.isActive) + } + + @Test + @DisplayName("사이즈 가격을 수정하고 salePriceCan이 basePriceCan보다 크면 거부한다") + fun shouldUpdateSizePriceAndRejectInvalidSalePrice() { + val updated = service.updateSizePrice( + GiftSize.SMALL, + GiftSizePriceCommand(basePriceCan = 100, salePriceCan = 80, isActive = true) + ) + + val exception = assertThrows(SodaException::class.java) { + service.updateSizePrice( + GiftSize.SMALL, + GiftSizePriceCommand(basePriceCan = 100, salePriceCan = 101, isActive = true) + ) + } + + assertEquals(GiftSize.SMALL, updated.sizeCode) + assertEquals(80, updated.salePriceCan) + assertEquals("common.error.invalid_request", exception.messageKey) + } + + @Test + @DisplayName("운영자는 발송 확인 선물을 사서함 도착 처리한다") + fun shouldMarkTrackingRegisteredGiftArrivedAtMailbox() { + val (gift, delivery) = saveGift(GiftStatus.TRACKING_REGISTERED) + + val result = service.arriveMailbox(APPLICATION_NO, NOW) + + assertEquals(GiftStatus.ARRIVED_AT_MAILBOX, result.status) + assertEquals(GiftStatus.ARRIVED_AT_MAILBOX, gift.status) + assertEquals(NOW, delivery.arrivedAtMailboxAt) + Mockito.verify(giftPushService).sendArrivedAtMailboxToSender(APPLICATION_NO, SENDER_MEMBER_ID) + Mockito.verify(giftPushService).sendArrivedAtMailboxToRecipient(APPLICATION_NO, RECIPIENT_MEMBER_ID) + } + + @Test + @DisplayName("사서함 도착 상태가 아니면 검수 완료 처리할 수 없다") + fun shouldRejectInspectionCompletionWhenGiftIsNotArrivedAtMailbox() { + saveGift(GiftStatus.TRACKING_REGISTERED) + + val exception = assertThrows(SodaException::class.java) { + service.completeInspection(APPLICATION_NO, NOW) + } + + assertEquals("common.error.invalid_request", exception.messageKey) + } + + @Test + @DisplayName("운영자는 사서함 도착 선물을 검수 완료 처리한다") + fun shouldCompleteInspectionForArrivedAtMailboxGift() { + val (gift, delivery) = saveGift(GiftStatus.ARRIVED_AT_MAILBOX) + + val result = service.completeInspection(APPLICATION_NO, NOW) + + assertEquals(GiftStatus.INSPECTION_COMPLETED, result.status) + assertEquals(GiftStatus.INSPECTION_COMPLETED, gift.status) + assertEquals(NOW, delivery.inspectionCompletedAt) + Mockito.verify(giftPushService).sendInspectionCompleted(APPLICATION_NO, RECIPIENT_MEMBER_ID) + } + + @Test + @DisplayName("운영자는 진행 중 선물을 전달 불가 처리한다") + fun shouldMarkActiveGiftUndeliverable() { + val (gift, delivery) = saveGift(GiftStatus.INSPECTION_COMPLETED) + + val result = service.markUndeliverable(APPLICATION_NO, UNDELIVERABLE_REASON, NOW) + + assertEquals(GiftStatus.UNDELIVERABLE, result.status) + assertEquals(GiftStatus.UNDELIVERABLE, gift.status) + assertEquals(NOW, delivery.undeliverableAt) + assertEquals(UNDELIVERABLE_REASON, delivery.undeliverableReason) + Mockito.verify(giftPushService).sendUndeliverableToSender(APPLICATION_NO, SENDER_MEMBER_ID) + Mockito.verify(giftPushService).sendUndeliverableToRecipient(APPLICATION_NO, RECIPIENT_MEMBER_ID) + } + + @Test + @DisplayName("전달 불가 사유가 비어 있거나 종료 상태면 전달 불가 처리할 수 없다") + fun shouldRejectUndeliverableWhenReasonBlankOrGiftTerminated() { + saveGift(GiftStatus.DELIVERED) + + val blankReasonException = assertThrows(SodaException::class.java) { + service.markUndeliverable(APPLICATION_NO, " ", NOW) + } + val terminatedStatusException = assertThrows(SodaException::class.java) { + service.markUndeliverable(APPLICATION_NO, UNDELIVERABLE_REASON, NOW) + } + + assertEquals("common.error.invalid_request", blankReasonException.messageKey) + assertEquals("common.error.invalid_request", terminatedStatusException.messageKey) + } + + @Test + @DisplayName("운영자는 검수 완료 선물을 전달 완료 처리한다") + fun shouldCompleteDeliveryForInspectionCompletedGift() { + val (gift, delivery) = saveGift(GiftStatus.INSPECTION_COMPLETED) + + val result = service.completeDelivery(APPLICATION_NO, NOW) + + assertEquals(GiftStatus.DELIVERED, result.status) + assertEquals(GiftStatus.DELIVERED, gift.status) + assertEquals(NOW, delivery.deliveredAt) + Mockito.verify(giftPushService).sendDeliveredToSender(APPLICATION_NO, SENDER_MEMBER_ID) + Mockito.verify(giftPushService).sendDeliveredToRecipient(APPLICATION_NO, RECIPIENT_MEMBER_ID) + Mockito.verifyNoMoreInteractions(giftPushService) + } + + @Test + @DisplayName("검수 완료 상태가 아니면 운영 전달 완료 처리할 수 없다") + fun shouldRejectDeliveryCompletionWhenGiftIsNotInspectionCompleted() { + saveGift(GiftStatus.ARRIVED_AT_MAILBOX) + + val exception = assertThrows(SodaException::class.java) { + service.completeDelivery(APPLICATION_NO, NOW) + } + + assertEquals("common.error.invalid_request", exception.messageKey) + } + + private fun saveGift(status: GiftStatus): Pair { + val gift = giftRepository.save( + Gift( + applicationNo = APPLICATION_NO, + senderMemberId = SENDER_MEMBER_ID, + recipientMemberId = RECIPIENT_MEMBER_ID, + status = status, + sizeCode = GiftSize.SMALL, + categoryId = 1L, + categoryNameSnapshot = "인형", + salePriceCan = 80, + canUsageId = 10L, + senderTermsAgreed = true, + senderPrivacyAgreed = true, + senderTermsAgreedAt = NOW.minusDays(1), + senderPrivacyAgreedAt = NOW.minusDays(1), + damageWaiverAgreed = true + ) + ) + val delivery = giftDeliveryRepository.save( + GiftDelivery( + giftId = requireNotNull(gift.id), + senderName = "홍길동", + senderPhoneNumber = "01012345678", + senderZipCode = "06234", + senderAddress = "서울시 강남구", + trackingDeadlineAt = NOW.minusDays(1), + trackingRegisteredAt = NOW.minusHours(1) + ) + ) + + return gift to delivery + } + + private companion object { + const val APPLICATION_NO = "G20260929000001" + const val SENDER_MEMBER_ID = 1L + const val RECIPIENT_MEMBER_ID = 2L + const val UNDELIVERABLE_REASON = "선물 접수 규격에 맞지 않습니다." + val NOW: LocalDateTime = LocalDateTime.of(2026, 10, 3, 12, 0) + } +}