Skip to content

Commit 34f8fd2

Browse files
authored
Merge pull request #215 from FC-InnerCircle-ICD2/feature/admin/dashboard
[Feature] 점주 대시보드 개발(#214)
2 parents 47a33b7 + 033a733 commit 34f8fd2

9 files changed

Lines changed: 413 additions & 0 deletions

File tree

application-admin/build.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,13 @@ dependencies {
2020
runtimeOnly(project(":infrastructure:payment-postgres"))
2121
runtimeOnly(project(":infrastructure:review-postgres"))
2222
implementation(project(":common"))
23+
implementation("io.github.oshai:kotlin-logging-jvm:7.0.3")
2324
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.0.2")
2425
implementation("org.springframework.boot:spring-boot-starter-web")
2526
implementation("org.springframework:spring-tx")
2627
implementation("org.springframework.boot:spring-boot-starter-validation")
2728
implementation("org.springframework.boot:spring-boot-starter-security")
2829
implementation("com.auth0:java-jwt:4.2.1")
2930
implementation("org.springframework.retry:spring-retry")
31+
testImplementation("io.mockk:mockk:1.13.3")
3032
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package org.fastcampus.applicationadmin.dashboard.controller
2+
3+
import org.fastcampus.applicationadmin.config.security.dto.AuthMember
4+
import org.fastcampus.applicationadmin.dashboard.dto.response.DashboardResponse
5+
import org.fastcampus.applicationadmin.dashboard.dto.response.Type
6+
import org.fastcampus.applicationadmin.dashboard.service.DashboardService
7+
import org.fastcampus.common.dto.APIResponseDTO
8+
import org.springframework.format.annotation.DateTimeFormat
9+
import org.springframework.http.HttpStatus
10+
import org.springframework.http.ResponseEntity
11+
import org.springframework.security.core.annotation.AuthenticationPrincipal
12+
import org.springframework.web.bind.annotation.GetMapping
13+
import org.springframework.web.bind.annotation.RequestMapping
14+
import org.springframework.web.bind.annotation.RequestParam
15+
import org.springframework.web.bind.annotation.RestController
16+
import java.time.LocalDate
17+
18+
@RestController
19+
@RequestMapping("/api/v1/dashboard")
20+
class DashboardController(
21+
private val service: DashboardService,
22+
) {
23+
@GetMapping
24+
fun getDashboard(
25+
@RequestParam(required = true) @DateTimeFormat(pattern = "yyyyMMdd") startDate: LocalDate,
26+
@RequestParam(required = true) @DateTimeFormat(pattern = "yyyyMMdd") endDate: LocalDate,
27+
@RequestParam(required = true) type: Type,
28+
@AuthenticationPrincipal authMember: AuthMember,
29+
): ResponseEntity<APIResponseDTO<DashboardResponse>> =
30+
ResponseEntity.ok(
31+
APIResponseDTO(
32+
HttpStatus.OK.value(),
33+
HttpStatus.OK.reasonPhrase,
34+
service.getDashboard(authMember.id, startDate, endDate, type.name),
35+
),
36+
)
37+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package org.fastcampus.applicationadmin.dashboard.dto.response
2+
3+
import org.fastcampus.order.entity.Order
4+
import java.time.LocalDateTime
5+
6+
sealed class SummaryResponse
7+
8+
data class DashboardResponse(
9+
val type: Type, // 요청 타입에 따라
10+
val summary: SummaryResponse, // SALES이면 SalesSummaryResponse, ORDERS이면 OrdersSummaryResponse
11+
val data: List<OrderHistory?>,
12+
)
13+
14+
data class SalesSummaryResponse(
15+
val totalPrice: Long,
16+
val avgPrice: Long,
17+
val avgPricePerTime: Long,
18+
val avgPricePerDay: Long,
19+
) : SummaryResponse()
20+
21+
data class OrdersSummaryResponse(
22+
val totalOrder: Long,
23+
val avgOrderPerDay: Long,
24+
val cancelOrders: Long,
25+
val cancelRate: Double,
26+
) : SummaryResponse()
27+
28+
data class OrderHistory(
29+
val orderTime: LocalDateTime,
30+
val menu: String,
31+
val price: Long,
32+
val status: Order.ClientStatus,
33+
)
34+
35+
enum class Type {
36+
SALES,
37+
ORDERS,
38+
}
39+
40+
fun Order.toOrderHistory() =
41+
orderSummary?.let {
42+
OrderHistory(
43+
orderTime = orderTime,
44+
menu = it,
45+
price = orderPrice,
46+
status = status.toClientStatus(),
47+
)
48+
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
package org.fastcampus.applicationadmin.dashboard.service
2+
3+
import io.github.oshai.kotlinlogging.KotlinLogging
4+
import org.fastcampus.applicationadmin.dashboard.dto.response.DashboardResponse
5+
import org.fastcampus.applicationadmin.dashboard.dto.response.OrdersSummaryResponse
6+
import org.fastcampus.applicationadmin.dashboard.dto.response.SalesSummaryResponse
7+
import org.fastcampus.applicationadmin.dashboard.dto.response.Type
8+
import org.fastcampus.applicationadmin.dashboard.dto.response.toOrderHistory
9+
import org.fastcampus.order.entity.Order
10+
import org.fastcampus.order.repository.OrderRepository
11+
import org.fastcampus.store.repository.StoreRepository
12+
import org.springframework.stereotype.Service
13+
import java.time.LocalDate
14+
import java.time.temporal.ChronoUnit
15+
16+
private val logger = KotlinLogging.logger {}
17+
18+
@Service
19+
class DashboardService(
20+
private val orderRepository: OrderRepository,
21+
private val storeRepository: StoreRepository,
22+
) {
23+
fun getDashboard(ownerId: Long, startDate: LocalDate, endDate: LocalDate, type: String): DashboardResponse? {
24+
val storeId: String = storeRepository.findByOwnerId(ownerId.toString())
25+
val startOfDay = startDate.atStartOfDay()
26+
val endOfDay = endDate.atTime(23, 59, 59)
27+
28+
val allOrders = orderRepository.findAllByStoreIdAndOrderTimeBetweenAndStatusNot(storeId, startOfDay, endOfDay, Order.Status.WAIT)
29+
val completedOrders = allOrders.filter { it.status == Order.Status.COMPLETED }
30+
31+
return when (type) {
32+
Type.SALES.name -> {
33+
val totalPrice = completedOrders.sumOf { it.paymentPrice }
34+
val avgPrice = if (completedOrders.isNotEmpty()) totalPrice / completedOrders.size else 0L
35+
val avgPricePerDay = computeAvgPricePerDay(completedOrders)
36+
val avgPricePerTime = computeAvgPricePerTime(completedOrders)
37+
38+
DashboardResponse(
39+
type = Type.SALES,
40+
summary = SalesSummaryResponse(
41+
totalPrice = totalPrice,
42+
avgPrice = avgPrice,
43+
avgPricePerDay = avgPricePerDay,
44+
avgPricePerTime = avgPricePerTime,
45+
),
46+
data = completedOrders.map { order ->
47+
val orderHistory = order.toOrderHistory()
48+
logger.debug { "원본 주문 데이터: $order, 변환된 OrderHistory: $orderHistory" }
49+
orderHistory
50+
},
51+
)
52+
}
53+
54+
Type.ORDERS.name -> {
55+
// 주문 관련 통계 계산
56+
val totalOrder = allOrders.size.toLong()
57+
val days = ChronoUnit.DAYS.between(startDate, endDate).toInt() + 1
58+
val avgOrderPerDay = if (days > 0) totalOrder / days else 0L
59+
val cancelOrders = allOrders.count { it.status.toClientStatus() == Order.ClientStatus.CANCEL }.toLong()
60+
val cancelRate = if (totalOrder > 0) (cancelOrders.toDouble() / totalOrder) * 100.0 else 0.0
61+
62+
DashboardResponse(
63+
type = Type.ORDERS,
64+
summary = OrdersSummaryResponse(
65+
totalOrder = totalOrder,
66+
avgOrderPerDay = avgOrderPerDay,
67+
cancelOrders = cancelOrders,
68+
cancelRate = cancelRate,
69+
),
70+
data = allOrders.map { order ->
71+
val orderHistory = order.toOrderHistory()
72+
logger.debug { "원본 주문 데이터: $order, 변환된 OrderHistory: $orderHistory" }
73+
orderHistory
74+
},
75+
)
76+
}
77+
78+
else -> {
79+
throw IllegalArgumentException("예상치 못한 타입입니다: $type")
80+
}
81+
}
82+
}
83+
}
84+
85+
// 일별 평균 매출 계산 예시 (날짜별 그룹핑)
86+
private fun computeAvgPricePerDay(orders: List<Order>): Long {
87+
val ordersByDay = orders.groupBy { it.orderTime.toLocalDate() }
88+
val dailyTotals = ordersByDay.map { (_, orders) -> orders.sumOf { it.orderPrice } }
89+
return if (dailyTotals.isNotEmpty()) dailyTotals.average().toLong() else 0L
90+
}
91+
92+
// 시간대별 평균 매출 계산 예시 (시간별 그룹핑, 단순 예시)
93+
private fun computeAvgPricePerTime(orders: List<Order>): Long {
94+
val ordersByHour = orders.groupBy { it.orderTime.hour }
95+
val hourlyTotals = ordersByHour.map { (_, orders) -> orders.sumOf { it.orderPrice } }
96+
return if (hourlyTotals.isNotEmpty()) hourlyTotals.average().toLong() else 0L
97+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
###
2+
GET http://localhost:8082/api/v1/dashboard?
3+
startDate=20250201&
4+
endDate=20250218&
5+
type=ORDERS
6+
Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJhY2Nlc3NUb2tlbiIsInJvbGUiOiJDRU8iLCJpZCI6MSwiZXhwIjoxNzQwNjMzNTA1fQ.-a2t2PMweMQSHrll2bcyeCOCnFyrxqME_p5FKdyKmLea0XavB51FRCnuBvtloPLzbp0FslrtJ69YCXhDllKq_A
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
package org.fastcampus.applicationadmin.dashboard.service
2+
3+
import io.mockk.every
4+
import io.mockk.mockk
5+
import io.mockk.verify
6+
import org.fastcampus.applicationadmin.dashboard.dto.response.OrdersSummaryResponse
7+
import org.fastcampus.applicationadmin.dashboard.dto.response.SalesSummaryResponse
8+
import org.fastcampus.applicationadmin.dashboard.dto.response.Type
9+
import org.fastcampus.applicationadmin.fixture.createOrderFixture
10+
import org.fastcampus.order.entity.Order
11+
import org.fastcampus.order.repository.OrderRepository
12+
import org.fastcampus.store.repository.StoreRepository
13+
import org.junit.jupiter.api.Test
14+
import strikt.api.expectThat
15+
import strikt.assertions.isEqualTo
16+
import java.time.LocalDate
17+
import java.time.LocalDateTime
18+
19+
class DashboardServiceTest {
20+
private val orderRepository: OrderRepository = mockk()
21+
private val storeRepository: StoreRepository = mockk()
22+
23+
private val dashboardService = DashboardService(
24+
orderRepository = orderRepository,
25+
storeRepository = storeRepository,
26+
)
27+
28+
@Test
29+
fun `getDashboard returns correct Sales dashboard response`() {
30+
// given
31+
val ownerId = 1L
32+
val storeId = "store_123"
33+
val startDate = LocalDate.of(2025, 2, 1)
34+
val endDate = LocalDate.of(2025, 2, 15)
35+
val type = Type.SALES.name
36+
37+
every { storeRepository.findByOwnerId(ownerId.toString()) } returns storeId
38+
39+
// 테스트용 Order 데이터 (3건 중 COMPLETED 2건만 SALES 계산 대상)
40+
val order1 = createOrderFixture(
41+
id = "order_1",
42+
storeId = storeId,
43+
status = Order.Status.COMPLETED,
44+
orderPrice = 15000L,
45+
paymentPrice = 15000L,
46+
orderTime = LocalDateTime.of(2025, 2, 2, 10, 0, 0),
47+
)
48+
val order2 = createOrderFixture(
49+
id = "order_2",
50+
storeId = storeId,
51+
status = Order.Status.WAIT, // WAIT는 제외됨
52+
orderPrice = 10000L,
53+
paymentPrice = 10000L,
54+
orderTime = LocalDateTime.of(2025, 2, 2, 11, 0, 0),
55+
)
56+
val order3 = createOrderFixture(
57+
id = "order_3",
58+
storeId = storeId,
59+
status = Order.Status.COMPLETED,
60+
orderPrice = 20000L,
61+
paymentPrice = 20000L,
62+
orderTime = LocalDateTime.of(2025, 2, 3, 9, 30, 0),
63+
)
64+
65+
every {
66+
orderRepository.findAllByStoreIdAndOrderTimeBetweenAndStatusNot(
67+
storeId,
68+
startDate.atStartOfDay(),
69+
endDate.atTime(23, 59, 59),
70+
Order.Status.WAIT,
71+
)
72+
} returns listOf(order1, order2, order3)
73+
74+
// when
75+
val dashboardResponse = dashboardService.getDashboard(ownerId, startDate, endDate, type)
76+
?: error("dashboardResponse is null")
77+
78+
// then
79+
// SALES 타입은 COMPLETED 주문만 사용함 (order1, order3)
80+
// totalPrice = 15000 + 20000 = 35000
81+
// avgPrice = 35000 / 2 = 17500
82+
// 단순 평균 로직이므로, 일별, 시간별 평균도 17500로 계산됨 (예시)
83+
expectThat(dashboardResponse) {
84+
get { type } isEqualTo Type.SALES.name
85+
get { summary } isEqualTo SalesSummaryResponse(
86+
totalPrice = 35000L,
87+
avgPrice = 17500L,
88+
avgPricePerDay = 17500L,
89+
avgPricePerTime = 17500L,
90+
)
91+
// data에 COMPLETED 주문만 변환되어 들어있으므로 2건이어야 함
92+
get { data.size } isEqualTo 2
93+
}
94+
95+
verify(exactly = 1) {
96+
storeRepository.findByOwnerId(ownerId.toString())
97+
}
98+
verify(exactly = 1) {
99+
orderRepository.findAllByStoreIdAndOrderTimeBetweenAndStatusNot(
100+
storeId,
101+
startDate.atStartOfDay(),
102+
endDate.atTime(23, 59, 59),
103+
Order.Status.WAIT,
104+
)
105+
}
106+
}
107+
108+
@Test
109+
fun `getDashboard returns correct Orders dashboard response`() {
110+
// given
111+
val ownerId = 1L
112+
val storeId = "store_123"
113+
val startDate = LocalDate.of(2025, 2, 1)
114+
val endDate = LocalDate.of(2025, 2, 15)
115+
val type = Type.ORDERS.name
116+
117+
every { storeRepository.findByOwnerId(ownerId.toString()) } returns storeId
118+
119+
// 테스트용 Order 데이터 - WAIT 상태 제외, 모두 포함
120+
val order1 = createOrderFixture(
121+
id = "order_1",
122+
storeId = storeId,
123+
status = Order.Status.RECEIVE,
124+
orderPrice = 15000L,
125+
paymentPrice = 15000L,
126+
orderTime = LocalDateTime.of(2025, 2, 2, 10, 0, 0),
127+
)
128+
val order2 = createOrderFixture(
129+
id = "order_2",
130+
storeId = storeId,
131+
status = Order.Status.CANCEL, // 취소 주문
132+
orderPrice = 10000L,
133+
paymentPrice = 10000L,
134+
orderTime = LocalDateTime.of(2025, 2, 2, 11, 0, 0),
135+
)
136+
val order3 = createOrderFixture(
137+
id = "order_3",
138+
storeId = storeId,
139+
status = Order.Status.COMPLETED,
140+
orderPrice = 20000L,
141+
paymentPrice = 20000L,
142+
orderTime = LocalDateTime.of(2025, 2, 3, 9, 30, 0),
143+
)
144+
145+
every {
146+
orderRepository.findAllByStoreIdAndOrderTimeBetweenAndStatusNot(
147+
storeId,
148+
startDate.atStartOfDay(),
149+
endDate.atTime(23, 59, 59),
150+
Order.Status.WAIT,
151+
)
152+
} returns listOf(order1, order2, order3)
153+
154+
// when
155+
val dashboardResponse = dashboardService.getDashboard(ownerId, startDate, endDate, type)
156+
?: error("dashboardResponse is null")
157+
158+
// then
159+
// ORDERS 타입은 모든 주문(WAIT 제외)을 사용함: 3건
160+
// cancelOrders는 order2가 취소 상태이므로 1건
161+
// avgOrderPerDay = totalOrder / days, 여기서는 테스트 환경에 따라 3건/15일 = 0 (정수 나눗셈)
162+
expectThat(dashboardResponse) {
163+
get { type } isEqualTo Type.ORDERS.name
164+
get { summary } isEqualTo OrdersSummaryResponse(
165+
totalOrder = 3L,
166+
avgOrderPerDay = 0L,
167+
cancelOrders = 1L,
168+
cancelRate = (1.0 / 3.0) * 100.0,
169+
)
170+
get { data.size } isEqualTo 3
171+
}
172+
173+
verify(exactly = 1) {
174+
storeRepository.findByOwnerId(ownerId.toString())
175+
}
176+
verify(exactly = 1) {
177+
orderRepository.findAllByStoreIdAndOrderTimeBetweenAndStatusNot(
178+
storeId,
179+
startDate.atStartOfDay(),
180+
endDate.atTime(23, 59, 59),
181+
Order.Status.WAIT,
182+
)
183+
}
184+
}
185+
}

0 commit comments

Comments
 (0)