feat(gift): 선물 관리자 서비스를 추가한다
This commit is contained in:
@@ -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<GiftCategoryResult> = 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<GiftSizePriceResult> = 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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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<Gift, GiftDelivery> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user