Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions docs/feature/513.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# Feature #513: 우산 상태 세분화 및 재고 정리

## 개요

우산 상태를 대여 가능/불가능(`rentable`, `missed`)에서 네 가지 상태(`status`)로 나눈다. (26-2 결정사항 [우산 관리 탭])

| status | 의미 | 대여 |
|--------|------|------|
| `AVAILABLE` | 사용 가능 | 가능 |
| `RENTED` | 대여중 | 불가 (대여중 예외) |
| `UNLOCATED` | 위치 미확인 | 불가 (분실 예외) |
| `LOST` | 분실 | 불가 (분실 예외) |

- 대여 검증은 `Umbrella.rentUmbrella(storeId)`가 한다. 빌리려는 매장에 없는 우산이면 상태와 관계없이 매장 불일치 예외를 먼저 던지고, 그다음 위 표대로 상태를 확인한다. 서비스가 상태를 꺼내 보지 않아서, 다른 곳에서 호출해도 분실·대여중 우산이 대여중으로 바뀌지 않는다.
- 대여하면 `RENTED`, 반납하면 반납 지점에서 `AVAILABLE`이 된다. 위치 미확인·분실 우산도 반납되면 사용 가능이 된다. (예전에는 반납돼도 분실 표시가 남아 대여할 수 없었다)
- 지점별 대여 가능 개수(앱 지도, 지점 상세)는 `AVAILABLE`만 센다.

## 하위 호환

FE가 `status`로 옮기기 전에도 지금처럼 동작하도록 기존 필드를 남겼다. FE가 옮기면 지운다.

- **DB**: `rentable`, `missed` 컬럼을 남기고 `status`가 바뀔 때 같이 바꾼다. (`rentable` = 사용 가능, `missed` = 위치 미확인 또는 분실)
- **응답**: 우산 목록에 `status` 추가, `rentable`은 `status`가 `AVAILABLE`이면 true.
- **등록 요청**: `status`가 없으면 `rentable`로 정하고, 둘 다 없으면 `AVAILABLE`.
- **수정 요청**: `status`가 없으면 `rentable`/`missed`로 정하고, 셋 다 없으면 상태를 바꾸지 않는다.
- 기존 값(`rentable`/`missed`)은 예전 통계와 같은 상태로 옮긴다.

| rentable | missed | status |
|----------|--------|--------|
| true | false | `AVAILABLE` |
| false | false | `RENTED` |
| (상관없음) | true | `LOST` |

- 단, 위치 미확인·분실 우산은 `rentable`/`missed` 수정 요청으로 사용 가능(`rentable=true, missed=false`)으로 바꿀 때만 상태가 바뀌고, 나머지는 지금 상태를 유지한다.
- 기존 FE 수정 모달은 `missed`를 항상 false로, `rentable`은 응답값으로 채워 보낸다. 위치 미확인·분실 우산의 응답 `rentable`은 false라서, 메모만 고쳐 저장해도 `rentable=false, missed=false`가 와서 대여중으로 바뀌는 것을 막는다.
- 예전에는 같은 경우에 분실 표시가 풀렸다. 재고 정리 후에는 대부분의 우산이 위치 미확인·분실이라 이 동작을 바꿨다.

## 통계 응답

| 필드 | 의미 |
|------|------|
| `rentableUmbrellaCount` | 사용 가능 |
| `rentedUmbrellaCount` | 대여중 |
| `unlocatedUmbrellaCount` | (신규) 위치 미확인 |
| `lostUmbrellaCount` | (신규) 분실 |
| `missingUmbrellaCount` | 위치 미확인 + 분실 (기존 필드, 기존 FE의 "분실 우산 개수"에 이 값이 보인다) |
| `missingRate` | 위치 미확인 + 분실 비율(%) |

상태별 개수는 `GROUP BY status` 쿼리 한 번으로 센다. (기존에는 상태마다 행을 전부 불러와서 셌다)

## 배포 순서

운영 DB에는 `ddl-auto`가 없어서 컬럼을 직접 추가해야 한다.

1. 머지 전: `src/main/resources/db/513_umbrella_status.sql` 실행 (`status` 컬럼 추가 + 기존 값으로 채우기)
2. 머지 → 맥미니 자동 배포
3. 배포 직후: `src/main/resources/db/513_umbrella_status_sync.sql` 실행. 1~2 사이에 예전 코드가 `rentable`/`missed`만 바꾼 우산을 `status`에 반영한다. 여러 번 실행해도 된다.

## 재고 정리 (운영진 목록을 받은 뒤)

`src/main/resources/db/513_umbrella_cleanup.sql`

- 9/27 기준 배치 우산(목록)에 새 관리번호(1~30)를 준다. 반납 안 된 대여가 있으면 대여중, 아니면 사용 가능.
- 목록 밖 우산은 마지막 대여일(대여 기록이 없으면 등록일) 기준 3개월 이내면 위치 미확인, 넘으면 분실. 반납 안 된 대여가 있어도 결정사항대로 분류한다.
- 새 관리번호와 겹치는 목록 밖 우산은 가장 큰 관리번호 뒤로 옮긴다.

## 하지 않은 것

- 3개월 기준을 주기적으로 자동 적용하지 않는다. 운영진이 원하면 별도로 스케줄러를 만든다.
- 위치 미확인 우산을 사용자가 매장에서 찾아 대여하려 해도 막힌다. (기존 분실 우산과 같음)
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.RestControllerAdvice
import upbrella.be.rent.exception.*
import upbrella.be.umbrella.exception.MissingUmbrellaException
import upbrella.be.umbrella.exception.NotAvailableUmbrellaException
import upbrella.be.umbrella.exception.UmbrellaStoreMissMatchException
import upbrella.be.util.CustomErrorResponse

@RestControllerAdvice
Expand Down
12 changes: 1 addition & 11 deletions src/main/kotlin/upbrella/be/rent/service/RentService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import upbrella.be.slack.SlackAlarmService
import upbrella.be.store.entity.StoreMeta
import upbrella.be.store.repository.StoreMetaReader
import upbrella.be.umbrella.entity.Umbrella
import upbrella.be.umbrella.exception.MissingUmbrellaException
import upbrella.be.umbrella.exception.NonExistingBorrowedHistoryException
import upbrella.be.umbrella.service.UmbrellaService
import upbrella.be.user.dto.response.AllHistoryResponse
Expand Down Expand Up @@ -71,16 +70,7 @@ class RentService(
}

val umbrella = umbrellaService.findUmbrellaByIdForRent(rentUmbrellaByUserRequest.umbrellaId)
if (umbrella.storeMeta.id != rentUmbrellaByUserRequest.storeId) {
throw UmbrellaStoreMissMatchException("[ERROR] 해당 우산은 해당 매장에 존재하지 않습니다.")
}
if (umbrella.missed) {
throw MissingUmbrellaException("[ERROR] 해당 우산은 분실되었습니다.")
}
if (!umbrella.rentable) {
throw NotAvailableUmbrellaException("[ERROR] 해당 우산은 대여중입니다.")
}
umbrella.rentUmbrella()
umbrella.rentUmbrella(rentUmbrellaByUserRequest.storeId)
val rentalStore = storeMetaReader.findById(rentUmbrellaByUserRequest.storeId)
val history = rentRepository.save(
History.ofCreatedByNewRent(umbrella, userToRent, rentalStore)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import upbrella.be.store.dto.response.QStoreMetaWithUmbrellaCount
import upbrella.be.store.dto.response.StoreMetaWithUmbrellaCount
import upbrella.be.store.entity.QStoreMeta
import upbrella.be.umbrella.entity.QUmbrella
import upbrella.be.umbrella.entity.UmbrellaStatus

class StoreMetaRepositoryImpl(
private val queryFactory: JPAQueryFactory
Expand All @@ -23,8 +24,7 @@ class StoreMetaRepositoryImpl(
.from(umbrella)
.where(
umbrella.storeMeta.id.eq(storeMeta.id),
umbrella.rentable.isTrue(),
umbrella.missed.isFalse(),
umbrella.status.eq(UmbrellaStatus.AVAILABLE),
umbrella.deleted.isFalse()
)
)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
package upbrella.be.umbrella.dto.request

import upbrella.be.umbrella.entity.UmbrellaStatus

data class UmbrellaCreateRequest (

val storeMetaId: Long,
val uuid: Long,
val rentable: Boolean,
val status: UmbrellaStatus? = null,
// 하위 호환용. status가 없을 때만 쓴다
val rentable: Boolean? = null,
val etc: String? = null
)
) {
fun toStatus(): UmbrellaStatus =
status ?: UmbrellaStatus.fromLegacy(rentable = rentable ?: true, missed = false)
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,31 @@
package upbrella.be.umbrella.dto.request

import upbrella.be.umbrella.entity.UmbrellaStatus

data class UmbrellaModifyRequest (

val storeMetaId: Long,
val uuid: Long,
val rentable: Boolean,
val missed: Boolean,
val status: UmbrellaStatus? = null,
// 하위 호환용. status가 없을 때만 쓴다
val rentable: Boolean? = null,
val missed: Boolean? = null,
val etc: String? = null
)
) {
// 상태 관련 값을 하나도 보내지 않으면 지금 상태를 유지한다
fun toStatus(current: UmbrellaStatus): UmbrellaStatus {
if (status != null) {
return status
}
if (rentable == null && missed == null) {
return current
}
val legacyStatus = UmbrellaStatus.fromLegacy(rentable = rentable ?: false, missed = missed ?: false)
// 기존 FE는 수정할 때 missed를 항상 false로, rentable은 응답값(위치 미확인·분실이면 false)으로 채워 보낸다.
// 그래서 위치 미확인·분실 우산은 대여 가능으로 바꿀 때만 상태를 바꾼다
if (current.isMissing() && legacyStatus != UmbrellaStatus.AVAILABLE) {
return current
}
return legacyStatus
}
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
package upbrella.be.umbrella.dto.response

import upbrella.be.umbrella.entity.UmbrellaStatus

data class UmbrellaResponse (

val id: Long,
val historyId: Long? = null,
val storeMetaId: Long? = null,
val storeName: String,
val uuid: Long,
val status: UmbrellaStatus,
// 하위 호환용. status가 사용 가능이면 true
val rentable: Boolean,
val etc: String? = null
)
Expand All @@ -17,7 +21,8 @@ data class UmbrellaResponse (
return UmbrellaResponse(
id = umbrellaWithHistory.id,
historyId = umbrellaWithHistory.historyId,
rentable = umbrellaWithHistory.rentable,
status = umbrellaWithHistory.status,
rentable = umbrellaWithHistory.status == UmbrellaStatus.AVAILABLE,
storeMetaId = umbrellaWithHistory.storeMeta.id,
storeName = umbrellaWithHistory.storeMeta.name,
uuid = umbrellaWithHistory.uuid,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,21 +1,23 @@
package upbrella.be.umbrella.dto.response

import upbrella.be.umbrella.entity.UmbrellaStatus

data class UmbrellaStatisticsResponse(
val totalUmbrellaCount: Long,
val rentableUmbrellaCount: Long,
val rentedUmbrellaCount: Long,
val missingUmbrellaCount: Long,
val rentableUmbrellaCount: Long, // 사용 가능
val rentedUmbrellaCount: Long, // 대여중
val unlocatedUmbrellaCount: Long, // 위치 미확인
val lostUmbrellaCount: Long, // 분실
val missingUmbrellaCount: Long, // 위치 미확인 + 분실 (기존 필드)
val missingRate: Double,
val totalRentCount: Long
) {
companion object {
fun fromCounts(
totalUmbrellaCount: Long,
rentableUmbrellaCount: Long,
rentedUmbrellaCount: Long,
missingUmbrellaCount: Long,
totalRentCount: Long
): UmbrellaStatisticsResponse {
fun of(countByStatus: Map<UmbrellaStatus, Long>, totalRentCount: Long): UmbrellaStatisticsResponse {
val totalUmbrellaCount = countByStatus.values.sum()
val unlocatedUmbrellaCount = countByStatus[UmbrellaStatus.UNLOCATED] ?: 0L
val lostUmbrellaCount = countByStatus[UmbrellaStatus.LOST] ?: 0L
val missingUmbrellaCount = unlocatedUmbrellaCount + lostUmbrellaCount
val missingRate = if (totalUmbrellaCount != 0L) {
100.0 * missingUmbrellaCount / totalUmbrellaCount
} else {
Expand All @@ -24,8 +26,10 @@ data class UmbrellaStatisticsResponse(

return UmbrellaStatisticsResponse(
totalUmbrellaCount = totalUmbrellaCount,
rentableUmbrellaCount = rentableUmbrellaCount,
rentedUmbrellaCount = rentedUmbrellaCount,
rentableUmbrellaCount = countByStatus[UmbrellaStatus.AVAILABLE] ?: 0L,
rentedUmbrellaCount = countByStatus[UmbrellaStatus.RENTED] ?: 0L,
unlocatedUmbrellaCount = unlocatedUmbrellaCount,
lostUmbrellaCount = lostUmbrellaCount,
missingUmbrellaCount = missingUmbrellaCount,
missingRate = missingRate,
totalRentCount = totalRentCount
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,16 @@ package upbrella.be.umbrella.dto.response

import com.querydsl.core.annotations.QueryProjection
import upbrella.be.store.entity.StoreMeta
import upbrella.be.umbrella.entity.UmbrellaStatus
import java.time.LocalDateTime

data class UmbrellaWithHistory @QueryProjection constructor(
val id: Long,
val storeMeta: StoreMeta,
val uuid: Long,
val rentable: Boolean,
val status: UmbrellaStatus,
val deleted: Boolean,
val createdAt: LocalDateTime,
val etc: String? = null,
val missed: Boolean,
val historyId: Long? = null
)
48 changes: 37 additions & 11 deletions src/main/kotlin/upbrella/be/umbrella/entity/Umbrella.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ package upbrella.be.umbrella.entity
import upbrella.be.store.entity.StoreMeta
import upbrella.be.umbrella.dto.request.UmbrellaCreateRequest
import upbrella.be.umbrella.dto.request.UmbrellaModifyRequest
import upbrella.be.umbrella.exception.MissingUmbrellaException
import upbrella.be.umbrella.exception.NotAvailableUmbrellaException
import upbrella.be.umbrella.exception.UmbrellaStoreMissMatchException
import java.time.LocalDateTime
import javax.persistence.*

Expand All @@ -12,27 +15,36 @@ class Umbrella(
@JoinColumn(name = "store_meta_id")
var storeMeta: StoreMeta,
var uuid: Long,
var rentable: Boolean,
status: UmbrellaStatus,
var deleted: Boolean,
val createdAt: LocalDateTime,
var etc: String?,
var missed: Boolean,
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
) {

@Enumerated(EnumType.STRING)
var status: UmbrellaStatus = status
private set

// 하위 호환용 컬럼. status가 바뀔 때 같이 바뀐다. FE가 status로 옮기면 지운다
var rentable: Boolean = status == UmbrellaStatus.AVAILABLE
private set

var missed: Boolean = status.isMissing()
private set

companion object {
@JvmStatic
fun ofCreated(request: UmbrellaCreateRequest, storeMeta: StoreMeta): Umbrella {
return Umbrella(
storeMeta = storeMeta,
uuid = request.uuid,
rentable = request.rentable,
status = request.toStatus(),
deleted = false,
createdAt = LocalDateTime.now(),
etc = request.etc,
missed = false
etc = request.etc
)
}
}
Expand All @@ -44,21 +56,35 @@ class Umbrella(
fun update(request: UmbrellaModifyRequest, storeMeta: StoreMeta) {
this.storeMeta = storeMeta
this.uuid = request.uuid
this.rentable = request.rentable
changeStatus(request.toStatus(status))
this.etc = request.etc
this.missed = request.missed
}

fun rentUmbrella() {
this.rentable = false
// 빌리려는 매장에 있는 사용 가능 우산만 대여중으로 바꾼다
fun rentUmbrella(storeId: Long) {
if (storeMeta.id != storeId) {
throw UmbrellaStoreMissMatchException("[ERROR] 해당 우산은 해당 매장에 존재하지 않습니다.")
}
when (status) {
UmbrellaStatus.AVAILABLE -> changeStatus(UmbrellaStatus.RENTED)
UmbrellaStatus.RENTED -> throw NotAvailableUmbrellaException("[ERROR] 해당 우산은 대여중입니다.")
UmbrellaStatus.UNLOCATED, UmbrellaStatus.LOST ->
throw MissingUmbrellaException("[ERROR] 해당 우산은 분실되었습니다.")
}
}

fun returnUmbrella(storeMeta: StoreMeta) {
this.storeMeta = storeMeta
this.rentable = true
changeStatus(UmbrellaStatus.AVAILABLE)
}

fun cannotBeRented(): Boolean {
return missed || deleted || !rentable
return deleted || status != UmbrellaStatus.AVAILABLE
}

private fun changeStatus(status: UmbrellaStatus) {
this.status = status
this.rentable = status == UmbrellaStatus.AVAILABLE
this.missed = status.isMissing()
}
}
19 changes: 19 additions & 0 deletions src/main/kotlin/upbrella/be/umbrella/entity/UmbrellaStatus.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package upbrella.be.umbrella.entity

enum class UmbrellaStatus {
AVAILABLE, // 사용 가능
RENTED, // 대여중
UNLOCATED, // 위치 미확인
LOST; // 분실

fun isMissing(): Boolean = this == UNLOCATED || this == LOST

companion object {
// status 없이 rentable/missed만 보내는 기존 요청을 상태로 바꾼다
fun fromLegacy(rentable: Boolean, missed: Boolean): UmbrellaStatus = when {
missed -> LOST
rentable -> AVAILABLE
else -> RENTED
}
}
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
package upbrella.be.rent.exception
package upbrella.be.umbrella.exception

class NotAvailableUmbrellaException(message: String) : RuntimeException(message)
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
package upbrella.be.rent.exception
package upbrella.be.umbrella.exception

class UmbrellaStoreMissMatchException(message: String) : RuntimeException(message)
Loading
Loading