|
@@ -0,0 +1,330 @@
|
|
|
|
|
+package com.etotem.num.service;
|
|
|
|
|
+
|
|
|
|
|
+import com.etotem.num.entity.Order;
|
|
|
|
|
+import com.etotem.num.entity.User;
|
|
|
|
|
+import com.etotem.num.entity.Commission;
|
|
|
|
|
+import com.etotem.num.entity.Withdraw;
|
|
|
|
|
+import com.etotem.num.repository.OrderRepository;
|
|
|
|
|
+import com.etotem.num.repository.UserRepository;
|
|
|
|
|
+import com.etotem.num.repository.CommissionRepository;
|
|
|
|
|
+import com.etotem.num.repository.WithdrawRepository;
|
|
|
|
|
+import org.junit.jupiter.api.Nested;
|
|
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
|
|
+import org.springframework.beans.factory.annotation.Autowired;
|
|
|
|
|
+import org.springframework.boot.test.context.SpringBootTest;
|
|
|
|
|
+import org.springframework.boot.test.mock.mockito.MockBean;
|
|
|
|
|
+
|
|
|
|
|
+import java.time.LocalDateTime;
|
|
|
|
|
+import java.util.*;
|
|
|
|
|
+
|
|
|
|
|
+import static org.junit.jupiter.api.Assertions.*;
|
|
|
|
|
+import static org.mockito.ArgumentMatchers.*;
|
|
|
|
|
+import static org.mockito.Mockito.*;
|
|
|
|
|
+
|
|
|
|
|
+@SpringBootTest
|
|
|
|
|
+class AnalyticsServiceTest {
|
|
|
|
|
+
|
|
|
|
|
+ @Autowired
|
|
|
|
|
+ private AnalyticsService analyticsService;
|
|
|
|
|
+
|
|
|
|
|
+ @MockBean
|
|
|
|
|
+ private OrderRepository orderRepository;
|
|
|
|
|
+
|
|
|
|
|
+ @MockBean
|
|
|
|
|
+ private UserRepository userRepository;
|
|
|
|
|
+
|
|
|
|
|
+ @MockBean
|
|
|
|
|
+ private CommissionRepository commissionRepository;
|
|
|
|
|
+
|
|
|
|
|
+ @MockBean
|
|
|
|
|
+ private WithdrawRepository withdrawRepository;
|
|
|
|
|
+
|
|
|
|
|
+ private final LocalDateTime start = LocalDateTime.of(2026, 1, 1, 0, 0);
|
|
|
|
|
+ private final LocalDateTime end = LocalDateTime.of(2026, 6, 30, 23, 59);
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== getSummaryStats ====================
|
|
|
|
|
+
|
|
|
|
|
+ @Nested
|
|
|
|
|
+ class SummaryStatsTests {
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void testEmptyData() {
|
|
|
|
|
+ when(orderRepository.findByStatusAndPaidAtBetween("paid", start, end))
|
|
|
|
|
+ .thenReturn(Collections.emptyList());
|
|
|
|
|
+ when(userRepository.count()).thenReturn(0L);
|
|
|
|
|
+ when(userRepository.findByCreatedAtBetweenOrderByCreatedAtAsc(start, end))
|
|
|
|
|
+ .thenReturn(Collections.emptyList());
|
|
|
|
|
+ when(userRepository.countByVipEndTimeAfterAndVipEndTimeIsNotNull(any()))
|
|
|
|
|
+ .thenReturn(0L);
|
|
|
|
|
+
|
|
|
|
|
+ Map<String, Object> stats = analyticsService.getSummaryStats(start, end);
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals(0L, stats.get("totalRevenue"));
|
|
|
|
|
+ assertEquals(0, stats.get("totalOrders"));
|
|
|
|
|
+ assertEquals(0L, stats.get("avgOrderValue"));
|
|
|
|
|
+ assertEquals(0L, stats.get("totalUsers"));
|
|
|
|
|
+ assertEquals(0, stats.get("newUsers"));
|
|
|
|
|
+ assertEquals(0, stats.get("newVip"));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void testWithData() {
|
|
|
|
|
+ Order o1 = new Order();
|
|
|
|
|
+ o1.setId(1L);
|
|
|
|
|
+ o1.setTotalFee(13100);
|
|
|
|
|
+ o1.setStatus("paid");
|
|
|
|
|
+ o1.setPaidAt(LocalDateTime.of(2026, 3, 15, 10, 0));
|
|
|
|
|
+
|
|
|
|
|
+ Order o2 = new Order();
|
|
|
|
|
+ o2.setId(2L);
|
|
|
|
|
+ o2.setTotalFee(131400);
|
|
|
|
|
+ o2.setStatus("paid");
|
|
|
|
|
+ o2.setPaidAt(LocalDateTime.of(2026, 4, 1, 14, 0));
|
|
|
|
|
+
|
|
|
|
|
+ when(orderRepository.findByStatusAndPaidAtBetween("paid", start, end))
|
|
|
|
|
+ .thenReturn(Arrays.asList(o1, o2));
|
|
|
|
|
+ when(userRepository.count()).thenReturn(100L);
|
|
|
|
|
+ when(userRepository.countByVipEndTimeAfterAndVipEndTimeIsNotNull(any()))
|
|
|
|
|
+ .thenReturn(15L);
|
|
|
|
|
+
|
|
|
|
|
+ User u1 = new User(); u1.setId(1L); u1.setVipType("annual");
|
|
|
|
|
+ User u2 = new User(); u2.setId(2L); u2.setVipType(null);
|
|
|
|
|
+ User u3 = new User(); u3.setId(3L); u3.setVipType("practitioner");
|
|
|
|
|
+ when(userRepository.findByCreatedAtBetweenOrderByCreatedAtAsc(start, end))
|
|
|
|
|
+ .thenReturn(Arrays.asList(u1, u2, u3));
|
|
|
|
|
+
|
|
|
|
|
+ Map<String, Object> stats = analyticsService.getSummaryStats(start, end);
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals(144500L, stats.get("totalRevenue"));
|
|
|
|
|
+ assertEquals(2, stats.get("totalOrders"));
|
|
|
|
|
+ assertEquals(72250L, stats.get("avgOrderValue"));
|
|
|
|
|
+ assertEquals(100L, stats.get("totalUsers"));
|
|
|
|
|
+ assertEquals(3, stats.get("newUsers"));
|
|
|
|
|
+ assertEquals(1, stats.get("newVip"));
|
|
|
|
|
+ assertEquals(15L, stats.get("vipUsers"));
|
|
|
|
|
+ assertEquals(15.0, (Double) stats.get("vipConversionRate"), 0.01);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== getRevenueTrend ====================
|
|
|
|
|
+
|
|
|
|
|
+ @Nested
|
|
|
|
|
+ class RevenueTrendTests {
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void testGroupByDay() {
|
|
|
|
|
+ Order o1 = new Order();
|
|
|
|
|
+ o1.setTotalFee(10000);
|
|
|
|
|
+ o1.setStatus("paid");
|
|
|
|
|
+ o1.setPaidAt(LocalDateTime.of(2026, 6, 1, 10, 0));
|
|
|
|
|
+
|
|
|
|
|
+ Order o2 = new Order();
|
|
|
|
|
+ o2.setTotalFee(20000);
|
|
|
|
|
+ o2.setStatus("paid");
|
|
|
|
|
+ o2.setPaidAt(LocalDateTime.of(2026, 6, 1, 14, 0));
|
|
|
|
|
+
|
|
|
|
|
+ Order o3 = new Order();
|
|
|
|
|
+ o3.setTotalFee(30000);
|
|
|
|
|
+ o3.setStatus("paid");
|
|
|
|
|
+ o3.setPaidAt(LocalDateTime.of(2026, 6, 2, 9, 0));
|
|
|
|
|
+
|
|
|
|
|
+ when(orderRepository.findByStatusAndPaidAtBetween("paid", start, end))
|
|
|
|
|
+ .thenReturn(Arrays.asList(o1, o2, o3));
|
|
|
|
|
+
|
|
|
|
|
+ List<Map<String, Object>> trend = analyticsService.getRevenueTrend(start, end, "day");
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals(2, trend.size());
|
|
|
|
|
+ assertEquals("2026-06-01", trend.get(0).get("date"));
|
|
|
|
|
+ assertEquals(30000L, trend.get(0).get("revenue"));
|
|
|
|
|
+ assertEquals(2, trend.get(0).get("orderCount"));
|
|
|
|
|
+ assertEquals(15000L, trend.get(0).get("avgOrderValue"));
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals("2026-06-02", trend.get(1).get("date"));
|
|
|
|
|
+ assertEquals(30000L, trend.get(1).get("revenue"));
|
|
|
|
|
+ assertEquals(1, trend.get(1).get("orderCount"));
|
|
|
|
|
+ assertEquals(30000L, trend.get(1).get("avgOrderValue"));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void testEmptyReturnsEmptyList() {
|
|
|
|
|
+ when(orderRepository.findByStatusAndPaidAtBetween("paid", start, end))
|
|
|
|
|
+ .thenReturn(Collections.emptyList());
|
|
|
|
|
+
|
|
|
|
|
+ List<Map<String, Object>> trend = analyticsService.getRevenueTrend(start, end, "day");
|
|
|
|
|
+
|
|
|
|
|
+ assertTrue(trend.isEmpty());
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== getUserGrowthTrend ====================
|
|
|
|
|
+
|
|
|
|
|
+ @Nested
|
|
|
|
|
+ class UserGrowthTrendTests {
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void testGroupByDay() {
|
|
|
|
|
+ User u1 = new User(); u1.setCreatedAt(LocalDateTime.of(2026, 6, 1, 10, 0)); u1.setVipType("annual");
|
|
|
|
|
+ User u2 = new User(); u2.setCreatedAt(LocalDateTime.of(2026, 6, 1, 14, 0)); u2.setVipType(null);
|
|
|
|
|
+ User u3 = new User(); u3.setCreatedAt(LocalDateTime.of(2026, 6, 2, 9, 0)); u3.setVipType("practitioner");
|
|
|
|
|
+
|
|
|
|
|
+ when(userRepository.findByCreatedAtBetweenOrderByCreatedAtAsc(start, end))
|
|
|
|
|
+ .thenReturn(Arrays.asList(u1, u2, u3));
|
|
|
|
|
+
|
|
|
|
|
+ List<Map<String, Object>> trend = analyticsService.getUserGrowthTrend(start, end, "day");
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals(2, trend.size());
|
|
|
|
|
+ assertEquals("2026-06-01", trend.get(0).get("date"));
|
|
|
|
|
+ assertEquals(2, trend.get(0).get("newUsers"));
|
|
|
|
|
+ assertEquals(1, trend.get(0).get("newVip"));
|
|
|
|
|
+ assertEquals(0, trend.get(0).get("newPractitioner"));
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals("2026-06-02", trend.get(1).get("date"));
|
|
|
|
|
+ assertEquals(1, trend.get(1).get("newUsers"));
|
|
|
|
|
+ assertEquals(0, trend.get(1).get("newVip"));
|
|
|
|
|
+ assertEquals(1, trend.get(1).get("newPractitioner"));
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== getConversionFunnel ====================
|
|
|
|
|
+
|
|
|
|
|
+ @Nested
|
|
|
|
|
+ class ConversionFunnelTests {
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void testFunnel() {
|
|
|
|
|
+ User c = new User(); c.setVipType("annual");
|
|
|
|
|
+ User s = new User(); s.setVipType("super_annual");
|
|
|
|
|
+ User p = new User(); p.setVipType("practitioner");
|
|
|
|
|
+ User n = new User(); n.setVipType(null);
|
|
|
|
|
+
|
|
|
|
|
+ when(userRepository.count()).thenReturn(100L);
|
|
|
|
|
+ when(userRepository.findByVipType("annual")).thenReturn(Arrays.asList(c, p));
|
|
|
|
|
+ when(userRepository.findByVipType("super_annual")).thenReturn(Collections.singletonList(s));
|
|
|
|
|
+ when(userRepository.findByVipType("practitioner")).thenReturn(Collections.singletonList(p));
|
|
|
|
|
+
|
|
|
|
|
+ Map<String, Object> funnel = analyticsService.getConversionFunnel();
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals(100L, funnel.get("registered"));
|
|
|
|
|
+ assertEquals(2, funnel.get("paidC"));
|
|
|
|
|
+ assertEquals(1, funnel.get("paidSuper"));
|
|
|
|
|
+ assertEquals(1, funnel.get("practitioner"));
|
|
|
|
|
+ assertEquals(1.0, (Double) funnel.get("conversionRate"), 0.01);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== getDistributionStats ====================
|
|
|
|
|
+
|
|
|
|
|
+ @Nested
|
|
|
|
|
+ class DistributionStatsTests {
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void testTopPromoters() {
|
|
|
|
|
+ User promoterA = new User();
|
|
|
|
|
+ promoterA.setId(1L);
|
|
|
|
|
+ promoterA.setNickname("推广A");
|
|
|
|
|
+ promoterA.setInvitedBy(99L);
|
|
|
|
|
+ promoterA.setDirectCount(5);
|
|
|
|
|
+ promoterA.setIndirectCount(3);
|
|
|
|
|
+
|
|
|
|
|
+ User promoterB = new User();
|
|
|
|
|
+ promoterB.setId(2L);
|
|
|
|
|
+ promoterB.setNickname("推广B");
|
|
|
|
|
+ promoterB.setInvitedBy(99L);
|
|
|
|
|
+ promoterB.setDirectCount(2);
|
|
|
|
|
+ promoterB.setIndirectCount(1);
|
|
|
|
|
+
|
|
|
|
|
+ User noPromo = new User();
|
|
|
|
|
+ noPromo.setId(3L);
|
|
|
|
|
+ noPromo.setInvitedBy(null);
|
|
|
|
|
+ noPromo.setDirectCount(0);
|
|
|
|
|
+ noPromo.setIndirectCount(0);
|
|
|
|
|
+
|
|
|
|
|
+ when(userRepository.findAll()).thenReturn(Arrays.asList(promoterA, promoterB, noPromo));
|
|
|
|
|
+ when(commissionRepository.findAll()).thenReturn(Collections.emptyList());
|
|
|
|
|
+
|
|
|
|
|
+ Commission c = new Commission();
|
|
|
|
|
+ c.setToUserId(1L);
|
|
|
|
|
+ c.setAmount(50000);
|
|
|
|
|
+ c.setStatus("settled");
|
|
|
|
|
+ when(commissionRepository.findByToUserIdOrderByCreatedAtDesc(1L))
|
|
|
|
|
+ .thenReturn(Collections.singletonList(c));
|
|
|
|
|
+ when(commissionRepository.findByToUserIdOrderByCreatedAtDesc(2L))
|
|
|
|
|
+ .thenReturn(Collections.emptyList());
|
|
|
|
|
+
|
|
|
|
|
+ Map<String, Object> stats = analyticsService.getDistributionStats();
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals(2, stats.get("totalPromoters"));
|
|
|
|
|
+ assertEquals(0L, stats.get("totalCommission"));
|
|
|
|
|
+ assertEquals(0L, stats.get("avgCommission"));
|
|
|
|
|
+
|
|
|
|
|
+ List<Map<String, Object>> top = (List<Map<String, Object>>) stats.get("topPromoters");
|
|
|
|
|
+ assertEquals(2, top.size());
|
|
|
|
|
+ assertEquals("推广A", top.get(0).get("nickname"));
|
|
|
|
|
+ assertEquals(5, top.get(0).get("directCount"));
|
|
|
|
|
+ assertEquals(50000L, top.get(0).get("totalCommission"));
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== getWithdrawStats ====================
|
|
|
|
|
+
|
|
|
|
|
+ @Nested
|
|
|
|
|
+ class WithdrawStatsTests {
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void testWithdrawStats() {
|
|
|
|
|
+ Withdraw w1 = new Withdraw();
|
|
|
|
|
+ w1.setAmount(10000);
|
|
|
|
|
+ w1.setStatus("success");
|
|
|
|
|
+ w1.setChannel("WECHAT_TRANSFER");
|
|
|
|
|
+ w1.setCreatedAt(LocalDateTime.of(2026, 3, 1, 10, 0));
|
|
|
|
|
+
|
|
|
|
|
+ Withdraw w2 = new Withdraw();
|
|
|
|
|
+ w2.setAmount(20000);
|
|
|
|
|
+ w2.setStatus("success");
|
|
|
|
|
+ w2.setChannel("REFUND");
|
|
|
|
|
+ w2.setCreatedAt(LocalDateTime.of(2026, 4, 1, 14, 0));
|
|
|
|
|
+
|
|
|
|
|
+ Withdraw w3 = new Withdraw();
|
|
|
|
|
+ w3.setAmount(5000);
|
|
|
|
|
+ w3.setStatus("rejected");
|
|
|
|
|
+ w3.setChannel("WECHAT_TRANSFER");
|
|
|
|
|
+ w3.setCreatedAt(LocalDateTime.of(2026, 5, 1, 9, 0));
|
|
|
|
|
+
|
|
|
|
|
+ when(withdrawRepository.findAll()).thenReturn(Arrays.asList(w1, w2, w3));
|
|
|
|
|
+
|
|
|
|
|
+ Map<String, Object> stats = analyticsService.getWithdrawStats(start, end);
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals(3, stats.get("totalCount"));
|
|
|
|
|
+ assertEquals(35000L, stats.get("totalAmount"));
|
|
|
|
|
+ assertEquals(30000L, stats.get("successAmount"));
|
|
|
|
|
+ assertEquals(2L, stats.get("successCount"));
|
|
|
|
|
+
|
|
|
|
|
+ Map<String, Long> byChannel = (Map<String, Long>) stats.get("byChannel");
|
|
|
|
|
+ assertEquals(2, byChannel.size());
|
|
|
|
|
+ assertEquals(15000L, byChannel.get("WECHAT_TRANSFER").longValue());
|
|
|
|
|
+ assertEquals(20000L, byChannel.get("REFUND").longValue());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void testDateFilter() {
|
|
|
|
|
+ Withdraw inside = new Withdraw();
|
|
|
|
|
+ inside.setAmount(10000);
|
|
|
|
|
+ inside.setStatus("success");
|
|
|
|
|
+ inside.setChannel("WECHAT_TRANSFER");
|
|
|
|
|
+ inside.setCreatedAt(LocalDateTime.of(2026, 3, 15, 10, 0));
|
|
|
|
|
+
|
|
|
|
|
+ Withdraw outside = new Withdraw();
|
|
|
|
|
+ outside.setAmount(5000);
|
|
|
|
|
+ outside.setStatus("pending");
|
|
|
|
|
+ outside.setChannel("WECHAT_TRANSFER");
|
|
|
|
|
+ outside.setCreatedAt(LocalDateTime.of(2025, 12, 1, 10, 0));
|
|
|
|
|
+
|
|
|
|
|
+ when(withdrawRepository.findAll()).thenReturn(Arrays.asList(inside, outside));
|
|
|
|
|
+
|
|
|
|
|
+ Map<String, Object> stats = analyticsService.getWithdrawStats(start, end);
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals(1, stats.get("totalCount"));
|
|
|
|
|
+ assertEquals(10000L, stats.get("totalAmount"));
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|