|
|
@@ -0,0 +1,1407 @@
|
|
|
+package com.haha.service.impl;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|
|
+import com.baomidou.mybatisplus.core.metadata.IPage;
|
|
|
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
|
|
+import com.haha.entity.dto.*;
|
|
|
+import com.haha.common.constant.OrderConstants;
|
|
|
+import com.haha.entity.*;
|
|
|
+import com.haha.mapper.*;
|
|
|
+import com.haha.service.StatisticsService;
|
|
|
+import lombok.extern.slf4j.Slf4j;
|
|
|
+import org.springframework.beans.factory.annotation.Autowired;
|
|
|
+import org.springframework.stereotype.Service;
|
|
|
+
|
|
|
+import java.math.BigDecimal;
|
|
|
+import java.math.RoundingMode;
|
|
|
+import java.time.LocalDate;
|
|
|
+import java.time.LocalDateTime;
|
|
|
+import java.time.LocalTime;
|
|
|
+import java.time.format.DateTimeFormatter;
|
|
|
+import java.time.temporal.ChronoUnit;
|
|
|
+import java.util.*;
|
|
|
+import java.util.stream.Collectors;
|
|
|
+
|
|
|
+@Slf4j
|
|
|
+@Service
|
|
|
+public class StatisticsServiceImpl implements StatisticsService {
|
|
|
+
|
|
|
+ @Autowired
|
|
|
+ private OrderMapper orderMapper;
|
|
|
+
|
|
|
+ @Autowired
|
|
|
+ private OrderItemMapper orderItemMapper;
|
|
|
+
|
|
|
+ @Autowired
|
|
|
+ private DeviceMapper deviceMapper;
|
|
|
+
|
|
|
+ @Autowired
|
|
|
+ private ShopMapper shopMapper;
|
|
|
+
|
|
|
+ @Autowired
|
|
|
+ private UserMapper userMapper;
|
|
|
+
|
|
|
+ @Autowired
|
|
|
+ private ProductMapper productMapper;
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public StatisticsOverviewVO getOverview(StatisticsQueryDTO queryDTO) {
|
|
|
+ StatisticsOverviewVO overview = new StatisticsOverviewVO();
|
|
|
+
|
|
|
+ LocalDate startDate = parseDate(queryDTO.getStartDate(), LocalDate.now().minusDays(30));
|
|
|
+ LocalDate endDate = parseDate(queryDTO.getEndDate(), LocalDate.now());
|
|
|
+
|
|
|
+ LocalDateTime startDateTime = startDate.atStartOfDay();
|
|
|
+ LocalDateTime endDateTime = endDate.atTime(LocalTime.MAX);
|
|
|
+
|
|
|
+ LambdaQueryWrapper<Order> orderWrapper = new LambdaQueryWrapper<>();
|
|
|
+ orderWrapper.eq(Order::getPayStatus, OrderConstants.PAY_STATUS_PAID)
|
|
|
+ .ge(Order::getPayTime, startDateTime)
|
|
|
+ .le(Order::getPayTime, endDateTime);
|
|
|
+
|
|
|
+ if (queryDTO.getShopId() != null) {
|
|
|
+ orderWrapper.eq(Order::getShopId, queryDTO.getShopId());
|
|
|
+ }
|
|
|
+
|
|
|
+ List<Order> orders = orderMapper.selectList(orderWrapper);
|
|
|
+
|
|
|
+ BigDecimal totalSales = orders.stream()
|
|
|
+ .map(o -> o.getTotalAmount() != null ? o.getTotalAmount() : BigDecimal.ZERO)
|
|
|
+ .reduce(BigDecimal.ZERO, BigDecimal::add);
|
|
|
+ overview.setTotalSales(totalSales.setScale(2, RoundingMode.HALF_UP));
|
|
|
+
|
|
|
+ overview.setTotalOrders(orders.size());
|
|
|
+
|
|
|
+ Set<Long> userIds = orders.stream()
|
|
|
+ .map(Order::getUserId)
|
|
|
+ .filter(Objects::nonNull)
|
|
|
+ .collect(Collectors.toSet());
|
|
|
+ overview.setTotalUsers(userIds.size());
|
|
|
+
|
|
|
+ long totalDevices = deviceMapper.selectCount(null);
|
|
|
+ long onlineDevices = deviceMapper.selectCount(
|
|
|
+ new LambdaQueryWrapper<Device>().eq(Device::getStatus, 1)
|
|
|
+ );
|
|
|
+ overview.setTotalDevices((int) totalDevices);
|
|
|
+ overview.setOnlineDevices((int) onlineDevices);
|
|
|
+
|
|
|
+ long totalShops = shopMapper.selectCount(null);
|
|
|
+ overview.setTotalShops((int) totalShops);
|
|
|
+
|
|
|
+ BigDecimal avgOrderAmount = orders.size() > 0
|
|
|
+ ? totalSales.divide(BigDecimal.valueOf(orders.size()), 2, RoundingMode.HALF_UP)
|
|
|
+ : BigDecimal.ZERO;
|
|
|
+ overview.setAvgOrderAmount(avgOrderAmount);
|
|
|
+
|
|
|
+ List<CategoryStatVO> categoryList = getCategoryOverview(queryDTO);
|
|
|
+ overview.setCategoryList(categoryList);
|
|
|
+
|
|
|
+ BigDecimal totalProfit = categoryList.stream()
|
|
|
+ .map(c -> c.getProfitAmount() != null ? c.getProfitAmount() : BigDecimal.ZERO)
|
|
|
+ .reduce(BigDecimal.ZERO, BigDecimal::add);
|
|
|
+ overview.setTotalProfit(totalProfit.setScale(2, RoundingMode.HALF_UP));
|
|
|
+
|
|
|
+ BigDecimal avgProfitRate = totalSales.compareTo(BigDecimal.ZERO) > 0
|
|
|
+ ? totalProfit.divide(totalSales, 4, RoundingMode.HALF_UP).multiply(new BigDecimal("100"))
|
|
|
+ : BigDecimal.ZERO;
|
|
|
+ overview.setAvgProfitRate(avgProfitRate.setScale(2, RoundingMode.HALF_UP));
|
|
|
+
|
|
|
+ return overview;
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public List<CategoryStatVO> getCategoryOverview(StatisticsQueryDTO queryDTO) {
|
|
|
+ LocalDate startDate = parseDate(queryDTO.getStartDate(), LocalDate.now().minusDays(30));
|
|
|
+ LocalDate endDate = parseDate(queryDTO.getEndDate(), LocalDate.now());
|
|
|
+
|
|
|
+ LocalDateTime startDateTime = startDate.atStartOfDay();
|
|
|
+ LocalDateTime endDateTime = endDate.atTime(LocalTime.MAX);
|
|
|
+
|
|
|
+ LambdaQueryWrapper<OrderItem> wrapper = new LambdaQueryWrapper<>();
|
|
|
+ wrapper.ge(OrderItem::getPayTime, startDateTime)
|
|
|
+ .le(OrderItem::getPayTime, endDateTime);
|
|
|
+
|
|
|
+ if (queryDTO.getShopId() != null) {
|
|
|
+ wrapper.eq(OrderItem::getShopId, queryDTO.getShopId());
|
|
|
+ }
|
|
|
+
|
|
|
+ List<OrderItem> items = orderItemMapper.selectList(wrapper);
|
|
|
+
|
|
|
+ Map<String, List<OrderItem>> categoryMap = items.stream()
|
|
|
+ .filter(item -> item.getProductType() != null)
|
|
|
+ .collect(Collectors.groupingBy(OrderItem::getProductType));
|
|
|
+
|
|
|
+ BigDecimal totalSales = items.stream()
|
|
|
+ .map(i -> i.getTotalAmount() != null ? i.getTotalAmount() : BigDecimal.ZERO)
|
|
|
+ .reduce(BigDecimal.ZERO, BigDecimal::add);
|
|
|
+
|
|
|
+ List<CategoryStatVO> result = new ArrayList<>();
|
|
|
+ for (Map.Entry<String, List<OrderItem>> entry : categoryMap.entrySet()) {
|
|
|
+ List<OrderItem> categoryItems = entry.getValue();
|
|
|
+
|
|
|
+ CategoryStatVO vo = new CategoryStatVO();
|
|
|
+ vo.setCategory(entry.getKey());
|
|
|
+ vo.setQuantity(categoryItems.stream().mapToInt(i -> i.getQuantity() != null ? i.getQuantity() : 0).sum());
|
|
|
+
|
|
|
+ BigDecimal salesAmount = categoryItems.stream()
|
|
|
+ .map(i -> i.getTotalAmount() != null ? i.getTotalAmount() : BigDecimal.ZERO)
|
|
|
+ .reduce(BigDecimal.ZERO, BigDecimal::add);
|
|
|
+ vo.setSalesAmount(salesAmount.setScale(2, RoundingMode.HALF_UP));
|
|
|
+
|
|
|
+ BigDecimal costAmount = categoryItems.stream()
|
|
|
+ .map(i -> i.getCostPrice() != null && i.getQuantity() != null
|
|
|
+ ? i.getCostPrice().multiply(BigDecimal.valueOf(i.getQuantity()))
|
|
|
+ : BigDecimal.ZERO)
|
|
|
+ .reduce(BigDecimal.ZERO, BigDecimal::add);
|
|
|
+ vo.setCostAmount(costAmount.setScale(2, RoundingMode.HALF_UP));
|
|
|
+
|
|
|
+ BigDecimal profitAmount = salesAmount.subtract(costAmount);
|
|
|
+ vo.setProfitAmount(profitAmount.setScale(2, RoundingMode.HALF_UP));
|
|
|
+
|
|
|
+ BigDecimal profitRate = salesAmount.compareTo(BigDecimal.ZERO) > 0
|
|
|
+ ? profitAmount.divide(salesAmount, 4, RoundingMode.HALF_UP).multiply(new BigDecimal("100"))
|
|
|
+ : BigDecimal.ZERO;
|
|
|
+ vo.setProfitRate(profitRate.setScale(2, RoundingMode.HALF_UP));
|
|
|
+
|
|
|
+ vo.setOrderCount((int) categoryItems.stream().map(OrderItem::getOrderId).distinct().count());
|
|
|
+
|
|
|
+ BigDecimal percentage = totalSales.compareTo(BigDecimal.ZERO) > 0
|
|
|
+ ? salesAmount.divide(totalSales, 4, RoundingMode.HALF_UP).multiply(new BigDecimal("100"))
|
|
|
+ : BigDecimal.ZERO;
|
|
|
+ vo.setPercentage(percentage.setScale(2, RoundingMode.HALF_UP));
|
|
|
+
|
|
|
+ result.add(vo);
|
|
|
+ }
|
|
|
+
|
|
|
+ result.sort((a, b) -> b.getSalesAmount().compareTo(a.getSalesAmount()));
|
|
|
+
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public TrendDataVO getCategoryTrend(StatisticsQueryDTO queryDTO) {
|
|
|
+ TrendDataVO trend = new TrendDataVO();
|
|
|
+
|
|
|
+ LocalDate startDate = parseDate(queryDTO.getStartDate(), LocalDate.now().minusDays(30));
|
|
|
+ LocalDate endDate = parseDate(queryDTO.getEndDate(), LocalDate.now());
|
|
|
+
|
|
|
+ List<String> dates = new ArrayList<>();
|
|
|
+ List<SeriesDataVO> seriesList = new ArrayList<>();
|
|
|
+
|
|
|
+ LambdaQueryWrapper<OrderItem> wrapper = new LambdaQueryWrapper<>();
|
|
|
+ wrapper.ge(OrderItem::getPayTime, startDate.atStartOfDay())
|
|
|
+ .le(OrderItem::getPayTime, endDate.atTime(LocalTime.MAX));
|
|
|
+
|
|
|
+ if (queryDTO.getShopId() != null) {
|
|
|
+ wrapper.eq(OrderItem::getShopId, queryDTO.getShopId());
|
|
|
+ }
|
|
|
+ if (queryDTO.getCategory() != null) {
|
|
|
+ wrapper.eq(OrderItem::getProductType, queryDTO.getCategory());
|
|
|
+ }
|
|
|
+
|
|
|
+ List<OrderItem> items = orderItemMapper.selectList(wrapper);
|
|
|
+
|
|
|
+ Map<String, Map<String, BigDecimal>> categoryDateAmount = new HashMap<>();
|
|
|
+ for (OrderItem item : items) {
|
|
|
+ String date = item.getPayTime().toLocalDate().toString();
|
|
|
+ String category = item.getProductType() != null ? item.getProductType() : "其他";
|
|
|
+ BigDecimal amount = item.getTotalAmount() != null ? item.getTotalAmount() : BigDecimal.ZERO;
|
|
|
+
|
|
|
+ categoryDateAmount.computeIfAbsent(category, k -> new HashMap<>())
|
|
|
+ .merge(date, amount, BigDecimal::add);
|
|
|
+ }
|
|
|
+
|
|
|
+ long days = ChronoUnit.DAYS.between(startDate, endDate) + 1;
|
|
|
+ for (int i = 0; i < days; i++) {
|
|
|
+ LocalDate date = startDate.plusDays(i);
|
|
|
+ dates.add(date.format(DateTimeFormatter.ofPattern("MM-dd")));
|
|
|
+ }
|
|
|
+ trend.setDates(dates);
|
|
|
+
|
|
|
+ Set<String> categories = categoryDateAmount.keySet();
|
|
|
+ for (String category : categories) {
|
|
|
+ SeriesDataVO series = new SeriesDataVO();
|
|
|
+ series.setName(category);
|
|
|
+
|
|
|
+ List<BigDecimal> data = new ArrayList<>();
|
|
|
+ for (int i = 0; i < days; i++) {
|
|
|
+ LocalDate date = startDate.plusDays(i);
|
|
|
+ BigDecimal amount = categoryDateAmount.getOrDefault(category, new HashMap<>())
|
|
|
+ .getOrDefault(date.toString(), BigDecimal.ZERO);
|
|
|
+ data.add(amount.setScale(2, RoundingMode.HALF_UP));
|
|
|
+ }
|
|
|
+ series.setData(data);
|
|
|
+ seriesList.add(series);
|
|
|
+ }
|
|
|
+
|
|
|
+ trend.setSeries(seriesList);
|
|
|
+ return trend;
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public IPage<ProductStatVO> getProductList(StatisticsQueryDTO queryDTO) {
|
|
|
+ queryDTO.validate();
|
|
|
+
|
|
|
+ LocalDate startDate = parseDate(queryDTO.getStartDate(), LocalDate.now().minusDays(30));
|
|
|
+ LocalDate endDate = parseDate(queryDTO.getEndDate(), LocalDate.now());
|
|
|
+
|
|
|
+ LocalDateTime startDateTime = startDate.atStartOfDay();
|
|
|
+ LocalDateTime endDateTime = endDate.atTime(LocalTime.MAX);
|
|
|
+
|
|
|
+ LambdaQueryWrapper<OrderItem> wrapper = new LambdaQueryWrapper<>();
|
|
|
+ wrapper.ge(OrderItem::getPayTime, startDateTime)
|
|
|
+ .le(OrderItem::getPayTime, endDateTime);
|
|
|
+
|
|
|
+ if (queryDTO.getShopId() != null) {
|
|
|
+ wrapper.eq(OrderItem::getShopId, queryDTO.getShopId());
|
|
|
+ }
|
|
|
+ if (queryDTO.getDeviceId() != null) {
|
|
|
+ wrapper.eq(OrderItem::getDeviceId, queryDTO.getDeviceId());
|
|
|
+ }
|
|
|
+ if (queryDTO.getCategory() != null) {
|
|
|
+ wrapper.eq(OrderItem::getProductType, queryDTO.getCategory());
|
|
|
+ }
|
|
|
+ if (queryDTO.getKeyword() != null) {
|
|
|
+ wrapper.and(w -> w.like(OrderItem::getProductName, queryDTO.getKeyword())
|
|
|
+ .or().like(OrderItem::getProductCode, queryDTO.getKeyword()));
|
|
|
+ }
|
|
|
+
|
|
|
+ List<OrderItem> items = orderItemMapper.selectList(wrapper);
|
|
|
+
|
|
|
+ Map<Long, List<OrderItem>> productMap = items.stream()
|
|
|
+ .filter(item -> item.getProductId() != null)
|
|
|
+ .collect(Collectors.groupingBy(OrderItem::getProductId));
|
|
|
+
|
|
|
+ List<ProductStatVO> resultList = new ArrayList<>();
|
|
|
+ for (Map.Entry<Long, List<OrderItem>> entry : productMap.entrySet()) {
|
|
|
+ List<OrderItem> productItems = entry.getValue();
|
|
|
+ OrderItem first = productItems.get(0);
|
|
|
+
|
|
|
+ ProductStatVO vo = new ProductStatVO();
|
|
|
+ vo.setProductId(entry.getKey());
|
|
|
+ vo.setProductCode(first.getProductCode());
|
|
|
+ vo.setProductName(first.getProductName());
|
|
|
+ vo.setCategory(first.getProductType());
|
|
|
+ vo.setQuantity(productItems.stream().mapToInt(i -> i.getQuantity() != null ? i.getQuantity() : 0).sum());
|
|
|
+
|
|
|
+ BigDecimal salesAmount = productItems.stream()
|
|
|
+ .map(i -> i.getTotalAmount() != null ? i.getTotalAmount() : BigDecimal.ZERO)
|
|
|
+ .reduce(BigDecimal.ZERO, BigDecimal::add);
|
|
|
+ vo.setSalesAmount(salesAmount.setScale(2, RoundingMode.HALF_UP));
|
|
|
+
|
|
|
+ BigDecimal costAmount = productItems.stream()
|
|
|
+ .map(i -> i.getCostPrice() != null && i.getQuantity() != null
|
|
|
+ ? i.getCostPrice().multiply(BigDecimal.valueOf(i.getQuantity()))
|
|
|
+ : BigDecimal.ZERO)
|
|
|
+ .reduce(BigDecimal.ZERO, BigDecimal::add);
|
|
|
+ vo.setCostAmount(costAmount.setScale(2, RoundingMode.HALF_UP));
|
|
|
+
|
|
|
+ BigDecimal profitAmount = salesAmount.subtract(costAmount);
|
|
|
+ vo.setProfitAmount(profitAmount.setScale(2, RoundingMode.HALF_UP));
|
|
|
+
|
|
|
+ BigDecimal profitRate = salesAmount.compareTo(BigDecimal.ZERO) > 0
|
|
|
+ ? profitAmount.divide(salesAmount, 4, RoundingMode.HALF_UP).multiply(new BigDecimal("100"))
|
|
|
+ : BigDecimal.ZERO;
|
|
|
+ vo.setProfitRate(profitRate.setScale(2, RoundingMode.HALF_UP));
|
|
|
+
|
|
|
+ vo.setOrderCount((int) productItems.stream().map(OrderItem::getOrderId).distinct().count());
|
|
|
+ vo.setUserCount((int) productItems.stream().map(OrderItem::getUserId).filter(Objects::nonNull).distinct().count());
|
|
|
+
|
|
|
+ resultList.add(vo);
|
|
|
+ }
|
|
|
+
|
|
|
+ String sortBy = queryDTO.getSortBy() != null ? queryDTO.getSortBy() : "salesAmount";
|
|
|
+ boolean asc = "asc".equalsIgnoreCase(queryDTO.getSortOrder());
|
|
|
+
|
|
|
+ Comparator<ProductStatVO> comparator = null;
|
|
|
+ switch (sortBy) {
|
|
|
+ case "quantity":
|
|
|
+ comparator = Comparator.comparing(ProductStatVO::getQuantity);
|
|
|
+ break;
|
|
|
+ case "profit":
|
|
|
+ comparator = Comparator.comparing(ProductStatVO::getProfitAmount);
|
|
|
+ break;
|
|
|
+ case "salesAmount":
|
|
|
+ default:
|
|
|
+ comparator = Comparator.comparing(ProductStatVO::getSalesAmount);
|
|
|
+ break;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!asc) {
|
|
|
+ comparator = comparator.reversed();
|
|
|
+ }
|
|
|
+ resultList.sort(comparator);
|
|
|
+
|
|
|
+ Page<ProductStatVO> page = new Page<>(queryDTO.getPage(), queryDTO.getPageSize());
|
|
|
+ int start = (queryDTO.getPage() - 1) * queryDTO.getPageSize();
|
|
|
+ int end = Math.min(start + queryDTO.getPageSize(), resultList.size());
|
|
|
+
|
|
|
+ page.setRecords(resultList.subList(start, end));
|
|
|
+ page.setTotal(resultList.size());
|
|
|
+
|
|
|
+ return page;
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public List<ProductStatVO> getProductTop(StatisticsQueryDTO queryDTO) {
|
|
|
+ LocalDate startDate = parseDate(queryDTO.getStartDate(), LocalDate.now().minusDays(30));
|
|
|
+ LocalDate endDate = parseDate(queryDTO.getEndDate(), LocalDate.now());
|
|
|
+ int limit = queryDTO.getLimit() != null ? queryDTO.getLimit() : 10;
|
|
|
+ String type = queryDTO.getType() != null ? queryDTO.getType() : "sales";
|
|
|
+
|
|
|
+ StatisticsQueryDTO listQuery = new StatisticsQueryDTO();
|
|
|
+ listQuery.setStartDate(startDate.toString());
|
|
|
+ listQuery.setEndDate(endDate.toString());
|
|
|
+ listQuery.setShopId(queryDTO.getShopId());
|
|
|
+ listQuery.setSortBy(type);
|
|
|
+ listQuery.setSortOrder("desc");
|
|
|
+ listQuery.setPage(1);
|
|
|
+ listQuery.setPageSize(limit);
|
|
|
+
|
|
|
+ IPage<ProductStatVO> page = getProductList(listQuery);
|
|
|
+ return page.getRecords();
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public TrendDataVO getProductTrend(Long productId, StatisticsQueryDTO queryDTO) {
|
|
|
+ TrendDataVO trend = new TrendDataVO();
|
|
|
+
|
|
|
+ LocalDate startDate = parseDate(queryDTO.getStartDate(), LocalDate.now().minusDays(30));
|
|
|
+ LocalDate endDate = parseDate(queryDTO.getEndDate(), LocalDate.now());
|
|
|
+
|
|
|
+ LambdaQueryWrapper<OrderItem> wrapper = new LambdaQueryWrapper<>();
|
|
|
+ wrapper.eq(OrderItem::getProductId, productId)
|
|
|
+ .ge(OrderItem::getPayTime, startDate.atStartOfDay())
|
|
|
+ .le(OrderItem::getPayTime, endDate.atTime(LocalTime.MAX));
|
|
|
+
|
|
|
+ List<OrderItem> items = orderItemMapper.selectList(wrapper);
|
|
|
+
|
|
|
+ Map<String, BigDecimal> dateAmount = items.stream()
|
|
|
+ .collect(Collectors.groupingBy(
|
|
|
+ i -> i.getPayTime().toLocalDate().toString(),
|
|
|
+ Collectors.reducing(BigDecimal.ZERO,
|
|
|
+ i -> i.getTotalAmount() != null ? i.getTotalAmount() : BigDecimal.ZERO,
|
|
|
+ BigDecimal::add)
|
|
|
+ ));
|
|
|
+
|
|
|
+ List<String> dates = new ArrayList<>();
|
|
|
+ List<BigDecimal> data = new ArrayList<>();
|
|
|
+
|
|
|
+ long days = ChronoUnit.DAYS.between(startDate, endDate) + 1;
|
|
|
+ for (int i = 0; i < days; i++) {
|
|
|
+ LocalDate date = startDate.plusDays(i);
|
|
|
+ dates.add(date.format(DateTimeFormatter.ofPattern("MM-dd")));
|
|
|
+ data.add(dateAmount.getOrDefault(date.toString(), BigDecimal.ZERO).setScale(2, RoundingMode.HALF_UP));
|
|
|
+ }
|
|
|
+
|
|
|
+ trend.setDates(dates);
|
|
|
+
|
|
|
+ SeriesDataVO series = new SeriesDataVO();
|
|
|
+ series.setName("销售额");
|
|
|
+ series.setData(data);
|
|
|
+ trend.setSeries(Collections.singletonList(series));
|
|
|
+
|
|
|
+ return trend;
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public Map<String, Object> getDeviceOverview(StatisticsQueryDTO queryDTO) {
|
|
|
+ Map<String, Object> result = new HashMap<>();
|
|
|
+
|
|
|
+ LocalDate startDate = parseDate(queryDTO.getStartDate(), LocalDate.now().minusDays(30));
|
|
|
+ LocalDate endDate = parseDate(queryDTO.getEndDate(), LocalDate.now());
|
|
|
+
|
|
|
+ long totalDevices = deviceMapper.selectCount(null);
|
|
|
+ long onlineDevices = deviceMapper.selectCount(
|
|
|
+ new LambdaQueryWrapper<Device>().eq(Device::getStatus, 1)
|
|
|
+ );
|
|
|
+
|
|
|
+ result.put("totalDevices", totalDevices);
|
|
|
+ result.put("onlineDevices", onlineDevices);
|
|
|
+ result.put("offlineDevices", totalDevices - onlineDevices);
|
|
|
+ result.put("onlineRate", totalDevices > 0
|
|
|
+ ? String.format("%.1f%%", onlineDevices * 100.0 / totalDevices)
|
|
|
+ : "0%");
|
|
|
+
|
|
|
+ LambdaQueryWrapper<Order> orderWrapper = new LambdaQueryWrapper<>();
|
|
|
+ orderWrapper.eq(Order::getPayStatus, OrderConstants.PAY_STATUS_PAID)
|
|
|
+ .ge(Order::getPayTime, startDate.atStartOfDay())
|
|
|
+ .le(Order::getPayTime, endDate.atTime(LocalTime.MAX));
|
|
|
+
|
|
|
+ if (queryDTO.getShopId() != null) {
|
|
|
+ orderWrapper.eq(Order::getShopId, queryDTO.getShopId());
|
|
|
+ }
|
|
|
+
|
|
|
+ List<Order> orders = orderMapper.selectList(orderWrapper);
|
|
|
+
|
|
|
+ BigDecimal totalSales = orders.stream()
|
|
|
+ .map(o -> o.getTotalAmount() != null ? o.getTotalAmount() : BigDecimal.ZERO)
|
|
|
+ .reduce(BigDecimal.ZERO, BigDecimal::add);
|
|
|
+ result.put("totalSales", totalSales.setScale(2, RoundingMode.HALF_UP));
|
|
|
+ result.put("totalOrders", orders.size());
|
|
|
+
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public IPage<DeviceStatVO> getDeviceList(StatisticsQueryDTO queryDTO) {
|
|
|
+ queryDTO.validate();
|
|
|
+
|
|
|
+ LocalDate startDate = parseDate(queryDTO.getStartDate(), LocalDate.now().minusDays(30));
|
|
|
+ LocalDate endDate = parseDate(queryDTO.getEndDate(), LocalDate.now());
|
|
|
+
|
|
|
+ LambdaQueryWrapper<Device> deviceWrapper = new LambdaQueryWrapper<>();
|
|
|
+ if (queryDTO.getShopId() != null) {
|
|
|
+ deviceWrapper.eq(Device::getShopId, queryDTO.getShopId());
|
|
|
+ }
|
|
|
+ if (queryDTO.getStatus() != null) {
|
|
|
+ deviceWrapper.eq(Device::getStatus, queryDTO.getStatus());
|
|
|
+ }
|
|
|
+ if (queryDTO.getKeyword() != null) {
|
|
|
+ deviceWrapper.and(w -> w.like(Device::getDeviceId, queryDTO.getKeyword())
|
|
|
+ .or().like(Device::getName, queryDTO.getKeyword()));
|
|
|
+ }
|
|
|
+
|
|
|
+ List<Device> devices = deviceMapper.selectList(deviceWrapper);
|
|
|
+
|
|
|
+ LambdaQueryWrapper<Order> orderWrapper = new LambdaQueryWrapper<>();
|
|
|
+ orderWrapper.eq(Order::getPayStatus, OrderConstants.PAY_STATUS_PAID)
|
|
|
+ .ge(Order::getPayTime, startDate.atStartOfDay())
|
|
|
+ .le(Order::getPayTime, endDate.atTime(LocalTime.MAX));
|
|
|
+
|
|
|
+ if (queryDTO.getShopId() != null) {
|
|
|
+ orderWrapper.eq(Order::getShopId, queryDTO.getShopId());
|
|
|
+ }
|
|
|
+
|
|
|
+ List<Order> orders = orderMapper.selectList(orderWrapper);
|
|
|
+
|
|
|
+ Map<String, List<Order>> deviceOrderMap = orders.stream()
|
|
|
+ .filter(o -> o.getDeviceId() != null)
|
|
|
+ .collect(Collectors.groupingBy(Order::getDeviceId));
|
|
|
+
|
|
|
+ List<DeviceStatVO> resultList = new ArrayList<>();
|
|
|
+ for (Device device : devices) {
|
|
|
+ DeviceStatVO vo = new DeviceStatVO();
|
|
|
+ vo.setDeviceId(device.getDeviceId());
|
|
|
+ vo.setDeviceName(device.getName());
|
|
|
+ vo.setShopId(device.getShopId());
|
|
|
+ vo.setStatus(device.getStatus());
|
|
|
+ vo.setStatusLabel(device.getStatus() != null && device.getStatus() == 1 ? "在线" : "离线");
|
|
|
+ vo.setStatusColor(device.getStatus() != null && device.getStatus() == 1 ? "success" : "info");
|
|
|
+
|
|
|
+ List<Order> deviceOrders = deviceOrderMap.getOrDefault(device.getDeviceId(), Collections.emptyList());
|
|
|
+
|
|
|
+ vo.setOrderCount(deviceOrders.size());
|
|
|
+ vo.setUserCount((int) deviceOrders.stream().map(Order::getUserId).filter(Objects::nonNull).distinct().count());
|
|
|
+
|
|
|
+ BigDecimal salesAmount = deviceOrders.stream()
|
|
|
+ .map(o -> o.getTotalAmount() != null ? o.getTotalAmount() : BigDecimal.ZERO)
|
|
|
+ .reduce(BigDecimal.ZERO, BigDecimal::add);
|
|
|
+ vo.setSalesAmount(salesAmount.setScale(2, RoundingMode.HALF_UP));
|
|
|
+
|
|
|
+ BigDecimal avgOrderAmount = deviceOrders.size() > 0
|
|
|
+ ? salesAmount.divide(BigDecimal.valueOf(deviceOrders.size()), 2, RoundingMode.HALF_UP)
|
|
|
+ : BigDecimal.ZERO;
|
|
|
+ vo.setAvgOrderAmount(avgOrderAmount);
|
|
|
+
|
|
|
+ long days = ChronoUnit.DAYS.between(startDate, endDate) + 1;
|
|
|
+ BigDecimal dailySales = salesAmount.divide(BigDecimal.valueOf(days), 2, RoundingMode.HALF_UP);
|
|
|
+ vo.setDailySalesAmount(dailySales);
|
|
|
+
|
|
|
+ if (device.getShopId() != null) {
|
|
|
+ Shop shop = shopMapper.selectById(device.getShopId());
|
|
|
+ if (shop != null) {
|
|
|
+ vo.setShopName(shop.getName());
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ resultList.add(vo);
|
|
|
+ }
|
|
|
+
|
|
|
+ String sortBy = queryDTO.getSortBy() != null ? queryDTO.getSortBy() : "salesAmount";
|
|
|
+ boolean asc = "asc".equalsIgnoreCase(queryDTO.getSortOrder());
|
|
|
+
|
|
|
+ Comparator<DeviceStatVO> comparator = null;
|
|
|
+ switch (sortBy) {
|
|
|
+ case "orderCount":
|
|
|
+ comparator = Comparator.comparing(DeviceStatVO::getOrderCount);
|
|
|
+ break;
|
|
|
+ case "salesAmount":
|
|
|
+ default:
|
|
|
+ comparator = Comparator.comparing(DeviceStatVO::getSalesAmount);
|
|
|
+ break;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!asc) {
|
|
|
+ comparator = comparator.reversed();
|
|
|
+ }
|
|
|
+ resultList.sort(comparator);
|
|
|
+
|
|
|
+ Page<DeviceStatVO> page = new Page<>(queryDTO.getPage(), queryDTO.getPageSize());
|
|
|
+ int start = (queryDTO.getPage() - 1) * queryDTO.getPageSize();
|
|
|
+ int end = Math.min(start + queryDTO.getPageSize(), resultList.size());
|
|
|
+
|
|
|
+ page.setRecords(resultList.subList(start, end));
|
|
|
+ page.setTotal(resultList.size());
|
|
|
+
|
|
|
+ return page;
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public TrendDataVO getDeviceTrend(String deviceId, StatisticsQueryDTO queryDTO) {
|
|
|
+ TrendDataVO trend = new TrendDataVO();
|
|
|
+
|
|
|
+ LocalDate startDate = parseDate(queryDTO.getStartDate(), LocalDate.now().minusDays(30));
|
|
|
+ LocalDate endDate = parseDate(queryDTO.getEndDate(), LocalDate.now());
|
|
|
+
|
|
|
+ LambdaQueryWrapper<Order> wrapper = new LambdaQueryWrapper<>();
|
|
|
+ wrapper.eq(Order::getDeviceId, deviceId)
|
|
|
+ .eq(Order::getPayStatus, OrderConstants.PAY_STATUS_PAID)
|
|
|
+ .ge(Order::getPayTime, startDate.atStartOfDay())
|
|
|
+ .le(Order::getPayTime, endDate.atTime(LocalTime.MAX));
|
|
|
+
|
|
|
+ List<Order> orders = orderMapper.selectList(wrapper);
|
|
|
+
|
|
|
+ Map<String, BigDecimal> dateAmount = orders.stream()
|
|
|
+ .collect(Collectors.groupingBy(
|
|
|
+ o -> o.getPayTime().toLocalDate().toString(),
|
|
|
+ Collectors.reducing(BigDecimal.ZERO,
|
|
|
+ o -> o.getTotalAmount() != null ? o.getTotalAmount() : BigDecimal.ZERO,
|
|
|
+ BigDecimal::add)
|
|
|
+ ));
|
|
|
+
|
|
|
+ List<String> dates = new ArrayList<>();
|
|
|
+ List<BigDecimal> data = new ArrayList<>();
|
|
|
+
|
|
|
+ long days = ChronoUnit.DAYS.between(startDate, endDate) + 1;
|
|
|
+ for (int i = 0; i < days; i++) {
|
|
|
+ LocalDate date = startDate.plusDays(i);
|
|
|
+ dates.add(date.format(DateTimeFormatter.ofPattern("MM-dd")));
|
|
|
+ data.add(dateAmount.getOrDefault(date.toString(), BigDecimal.ZERO).setScale(2, RoundingMode.HALF_UP));
|
|
|
+ }
|
|
|
+
|
|
|
+ trend.setDates(dates);
|
|
|
+
|
|
|
+ SeriesDataVO series = new SeriesDataVO();
|
|
|
+ series.setName("销售额");
|
|
|
+ series.setData(data);
|
|
|
+ trend.setSeries(Collections.singletonList(series));
|
|
|
+
|
|
|
+ return trend;
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public Map<String, Object> getShopOverview(StatisticsQueryDTO queryDTO) {
|
|
|
+ Map<String, Object> result = new HashMap<>();
|
|
|
+
|
|
|
+ LocalDate startDate = parseDate(queryDTO.getStartDate(), LocalDate.now().minusDays(30));
|
|
|
+ LocalDate endDate = parseDate(queryDTO.getEndDate(), LocalDate.now());
|
|
|
+
|
|
|
+ long totalShops = shopMapper.selectCount(null);
|
|
|
+ long activeShops = shopMapper.selectCount(
|
|
|
+ new LambdaQueryWrapper<Shop>().eq(Shop::getStatus, 1)
|
|
|
+ );
|
|
|
+
|
|
|
+ result.put("totalShops", totalShops);
|
|
|
+ result.put("activeShops", activeShops);
|
|
|
+
|
|
|
+ LambdaQueryWrapper<Order> orderWrapper = new LambdaQueryWrapper<>();
|
|
|
+ orderWrapper.eq(Order::getPayStatus, OrderConstants.PAY_STATUS_PAID)
|
|
|
+ .ge(Order::getPayTime, startDate.atStartOfDay())
|
|
|
+ .le(Order::getPayTime, endDate.atTime(LocalTime.MAX));
|
|
|
+
|
|
|
+ List<Order> orders = orderMapper.selectList(orderWrapper);
|
|
|
+
|
|
|
+ BigDecimal totalSales = orders.stream()
|
|
|
+ .map(o -> o.getTotalAmount() != null ? o.getTotalAmount() : BigDecimal.ZERO)
|
|
|
+ .reduce(BigDecimal.ZERO, BigDecimal::add);
|
|
|
+ result.put("totalSales", totalSales.setScale(2, RoundingMode.HALF_UP));
|
|
|
+ result.put("totalOrders", orders.size());
|
|
|
+
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public IPage<ShopStatVO> getShopList(StatisticsQueryDTO queryDTO) {
|
|
|
+ queryDTO.validate();
|
|
|
+
|
|
|
+ LocalDate startDate = parseDate(queryDTO.getStartDate(), LocalDate.now().minusDays(30));
|
|
|
+ LocalDate endDate = parseDate(queryDTO.getEndDate(), LocalDate.now());
|
|
|
+
|
|
|
+ LambdaQueryWrapper<Shop> shopWrapper = new LambdaQueryWrapper<>();
|
|
|
+ if (queryDTO.getProvince() != null) {
|
|
|
+ shopWrapper.eq(Shop::getProvince, queryDTO.getProvince());
|
|
|
+ }
|
|
|
+ if (queryDTO.getCity() != null) {
|
|
|
+ shopWrapper.eq(Shop::getCity, queryDTO.getCity());
|
|
|
+ }
|
|
|
+ if (queryDTO.getDistrict() != null) {
|
|
|
+ shopWrapper.eq(Shop::getDistrict, queryDTO.getDistrict());
|
|
|
+ }
|
|
|
+ if (queryDTO.getStatus() != null) {
|
|
|
+ shopWrapper.eq(Shop::getStatus, queryDTO.getStatus());
|
|
|
+ }
|
|
|
+ if (queryDTO.getKeyword() != null) {
|
|
|
+ shopWrapper.like(Shop::getName, queryDTO.getKeyword());
|
|
|
+ }
|
|
|
+
|
|
|
+ List<Shop> shops = shopMapper.selectList(shopWrapper);
|
|
|
+
|
|
|
+ LambdaQueryWrapper<Order> orderWrapper = new LambdaQueryWrapper<>();
|
|
|
+ orderWrapper.eq(Order::getPayStatus, OrderConstants.PAY_STATUS_PAID)
|
|
|
+ .ge(Order::getPayTime, startDate.atStartOfDay())
|
|
|
+ .le(Order::getPayTime, endDate.atTime(LocalTime.MAX));
|
|
|
+
|
|
|
+ List<Order> orders = orderMapper.selectList(orderWrapper);
|
|
|
+
|
|
|
+ Map<Long, List<Order>> shopOrderMap = orders.stream()
|
|
|
+ .filter(o -> o.getShopId() != null)
|
|
|
+ .collect(Collectors.groupingBy(Order::getShopId));
|
|
|
+
|
|
|
+ List<ShopStatVO> resultList = new ArrayList<>();
|
|
|
+ for (Shop shop : shops) {
|
|
|
+ ShopStatVO vo = new ShopStatVO();
|
|
|
+ vo.setShopId(shop.getId());
|
|
|
+ vo.setShopName(shop.getName());
|
|
|
+ vo.setProvince(shop.getProvince());
|
|
|
+ vo.setCity(shop.getCity());
|
|
|
+ vo.setDistrict(shop.getDistrict());
|
|
|
+ vo.setAddress(shop.getAddress());
|
|
|
+ vo.setStatus(shop.getStatus());
|
|
|
+ vo.setStatusLabel(shop.getStatus() != null && shop.getStatus() == 1 ? "启用" : "禁用");
|
|
|
+
|
|
|
+ long deviceCount = deviceMapper.selectCount(
|
|
|
+ new LambdaQueryWrapper<Device>().eq(Device::getShopId, shop.getId())
|
|
|
+ );
|
|
|
+ vo.setDeviceCount((int) deviceCount);
|
|
|
+
|
|
|
+ List<Order> shopOrders = shopOrderMap.getOrDefault(shop.getId(), Collections.emptyList());
|
|
|
+
|
|
|
+ vo.setOrderCount(shopOrders.size());
|
|
|
+ vo.setUserCount((int) shopOrders.stream().map(Order::getUserId).filter(Objects::nonNull).distinct().count());
|
|
|
+
|
|
|
+ BigDecimal salesAmount = shopOrders.stream()
|
|
|
+ .map(o -> o.getTotalAmount() != null ? o.getTotalAmount() : BigDecimal.ZERO)
|
|
|
+ .reduce(BigDecimal.ZERO, BigDecimal::add);
|
|
|
+ vo.setSalesAmount(salesAmount.setScale(2, RoundingMode.HALF_UP));
|
|
|
+
|
|
|
+ BigDecimal avgOrderAmount = shopOrders.size() > 0
|
|
|
+ ? salesAmount.divide(BigDecimal.valueOf(shopOrders.size()), 2, RoundingMode.HALF_UP)
|
|
|
+ : BigDecimal.ZERO;
|
|
|
+ vo.setAvgOrderAmount(avgOrderAmount);
|
|
|
+
|
|
|
+ long days = ChronoUnit.DAYS.between(startDate, endDate) + 1;
|
|
|
+ BigDecimal dailySales = salesAmount.divide(BigDecimal.valueOf(days), 2, RoundingMode.HALF_UP);
|
|
|
+ vo.setDailySalesAmount(dailySales);
|
|
|
+
|
|
|
+ BigDecimal deviceOutput = deviceCount > 0
|
|
|
+ ? salesAmount.divide(BigDecimal.valueOf(deviceCount), 2, RoundingMode.HALF_UP)
|
|
|
+ : BigDecimal.ZERO;
|
|
|
+ vo.setDeviceOutput(deviceOutput);
|
|
|
+
|
|
|
+ resultList.add(vo);
|
|
|
+ }
|
|
|
+
|
|
|
+ String sortBy = queryDTO.getSortBy() != null ? queryDTO.getSortBy() : "salesAmount";
|
|
|
+ boolean asc = "asc".equalsIgnoreCase(queryDTO.getSortOrder());
|
|
|
+
|
|
|
+ Comparator<ShopStatVO> comparator = null;
|
|
|
+ switch (sortBy) {
|
|
|
+ case "orderCount":
|
|
|
+ comparator = Comparator.comparing(ShopStatVO::getOrderCount);
|
|
|
+ break;
|
|
|
+ case "profit":
|
|
|
+ comparator = Comparator.comparing(ShopStatVO::getProfitAmount);
|
|
|
+ break;
|
|
|
+ case "salesAmount":
|
|
|
+ default:
|
|
|
+ comparator = Comparator.comparing(ShopStatVO::getSalesAmount);
|
|
|
+ break;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!asc) {
|
|
|
+ comparator = comparator.reversed();
|
|
|
+ }
|
|
|
+ resultList.sort(comparator);
|
|
|
+
|
|
|
+ Page<ShopStatVO> page = new Page<>(queryDTO.getPage(), queryDTO.getPageSize());
|
|
|
+ int start = (queryDTO.getPage() - 1) * queryDTO.getPageSize();
|
|
|
+ int end = Math.min(start + queryDTO.getPageSize(), resultList.size());
|
|
|
+
|
|
|
+ page.setRecords(resultList.subList(start, end));
|
|
|
+ page.setTotal(resultList.size());
|
|
|
+
|
|
|
+ return page;
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public List<ShopStatVO> getShopRanking(StatisticsQueryDTO queryDTO) {
|
|
|
+ LocalDate startDate = parseDate(queryDTO.getStartDate(), LocalDate.now().minusDays(30));
|
|
|
+ LocalDate endDate = parseDate(queryDTO.getEndDate(), LocalDate.now());
|
|
|
+ int limit = queryDTO.getLimit() != null ? queryDTO.getLimit() : 10;
|
|
|
+ String type = queryDTO.getType() != null ? queryDTO.getType() : "sales";
|
|
|
+
|
|
|
+ StatisticsQueryDTO listQuery = new StatisticsQueryDTO();
|
|
|
+ listQuery.setStartDate(startDate.toString());
|
|
|
+ listQuery.setEndDate(endDate.toString());
|
|
|
+ listQuery.setProvince(queryDTO.getProvince());
|
|
|
+ listQuery.setCity(queryDTO.getCity());
|
|
|
+ listQuery.setSortBy(type);
|
|
|
+ listQuery.setSortOrder("desc");
|
|
|
+ listQuery.setPage(1);
|
|
|
+ listQuery.setPageSize(limit);
|
|
|
+
|
|
|
+ IPage<ShopStatVO> page = getShopList(listQuery);
|
|
|
+ return page.getRecords();
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public TrendDataVO getShopTrend(Long shopId, StatisticsQueryDTO queryDTO) {
|
|
|
+ TrendDataVO trend = new TrendDataVO();
|
|
|
+
|
|
|
+ LocalDate startDate = parseDate(queryDTO.getStartDate(), LocalDate.now().minusDays(30));
|
|
|
+ LocalDate endDate = parseDate(queryDTO.getEndDate(), LocalDate.now());
|
|
|
+
|
|
|
+ LambdaQueryWrapper<Order> wrapper = new LambdaQueryWrapper<>();
|
|
|
+ wrapper.eq(Order::getShopId, shopId)
|
|
|
+ .eq(Order::getPayStatus, OrderConstants.PAY_STATUS_PAID)
|
|
|
+ .ge(Order::getPayTime, startDate.atStartOfDay())
|
|
|
+ .le(Order::getPayTime, endDate.atTime(LocalTime.MAX));
|
|
|
+
|
|
|
+ List<Order> orders = orderMapper.selectList(wrapper);
|
|
|
+
|
|
|
+ Map<String, BigDecimal> dateAmount = orders.stream()
|
|
|
+ .collect(Collectors.groupingBy(
|
|
|
+ o -> o.getPayTime().toLocalDate().toString(),
|
|
|
+ Collectors.reducing(BigDecimal.ZERO,
|
|
|
+ o -> o.getTotalAmount() != null ? o.getTotalAmount() : BigDecimal.ZERO,
|
|
|
+ BigDecimal::add)
|
|
|
+ ));
|
|
|
+
|
|
|
+ List<String> dates = new ArrayList<>();
|
|
|
+ List<BigDecimal> data = new ArrayList<>();
|
|
|
+
|
|
|
+ long days = ChronoUnit.DAYS.between(startDate, endDate) + 1;
|
|
|
+ for (int i = 0; i < days; i++) {
|
|
|
+ LocalDate date = startDate.plusDays(i);
|
|
|
+ dates.add(date.format(DateTimeFormatter.ofPattern("MM-dd")));
|
|
|
+ data.add(dateAmount.getOrDefault(date.toString(), BigDecimal.ZERO).setScale(2, RoundingMode.HALF_UP));
|
|
|
+ }
|
|
|
+
|
|
|
+ trend.setDates(dates);
|
|
|
+
|
|
|
+ SeriesDataVO series = new SeriesDataVO();
|
|
|
+ series.setName("销售额");
|
|
|
+ series.setData(data);
|
|
|
+ trend.setSeries(Collections.singletonList(series));
|
|
|
+
|
|
|
+ return trend;
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public Map<String, Object> getProfitOverview(StatisticsQueryDTO queryDTO) {
|
|
|
+ Map<String, Object> result = new HashMap<>();
|
|
|
+
|
|
|
+ LocalDate startDate = parseDate(queryDTO.getStartDate(), LocalDate.now().minusDays(30));
|
|
|
+ LocalDate endDate = parseDate(queryDTO.getEndDate(), LocalDate.now());
|
|
|
+
|
|
|
+ LocalDateTime startDateTime = startDate.atStartOfDay();
|
|
|
+ LocalDateTime endDateTime = endDate.atTime(LocalTime.MAX);
|
|
|
+
|
|
|
+ LambdaQueryWrapper<OrderItem> itemWrapper = new LambdaQueryWrapper<>();
|
|
|
+ itemWrapper.ge(OrderItem::getPayTime, startDateTime)
|
|
|
+ .le(OrderItem::getPayTime, endDateTime);
|
|
|
+
|
|
|
+ List<OrderItem> items = orderItemMapper.selectList(itemWrapper);
|
|
|
+
|
|
|
+ BigDecimal salesAmount = items.stream()
|
|
|
+ .map(i -> i.getTotalAmount() != null ? i.getTotalAmount() : BigDecimal.ZERO)
|
|
|
+ .reduce(BigDecimal.ZERO, BigDecimal::add);
|
|
|
+ result.put("salesAmount", salesAmount.setScale(2, RoundingMode.HALF_UP));
|
|
|
+
|
|
|
+ BigDecimal costAmount = items.stream()
|
|
|
+ .map(i -> i.getCostPrice() != null && i.getQuantity() != null
|
|
|
+ ? i.getCostPrice().multiply(BigDecimal.valueOf(i.getQuantity()))
|
|
|
+ : BigDecimal.ZERO)
|
|
|
+ .reduce(BigDecimal.ZERO, BigDecimal::add);
|
|
|
+ result.put("costAmount", costAmount.setScale(2, RoundingMode.HALF_UP));
|
|
|
+
|
|
|
+ BigDecimal grossProfit = salesAmount.subtract(costAmount);
|
|
|
+ result.put("grossProfit", grossProfit.setScale(2, RoundingMode.HALF_UP));
|
|
|
+
|
|
|
+ BigDecimal grossProfitRate = salesAmount.compareTo(BigDecimal.ZERO) > 0
|
|
|
+ ? grossProfit.divide(salesAmount, 4, RoundingMode.HALF_UP).multiply(new BigDecimal("100"))
|
|
|
+ : BigDecimal.ZERO;
|
|
|
+ result.put("grossProfitRate", grossProfitRate.setScale(2, RoundingMode.HALF_UP));
|
|
|
+
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public IPage<ProfitStatVO> getProfitList(StatisticsQueryDTO queryDTO) {
|
|
|
+ queryDTO.validate();
|
|
|
+
|
|
|
+ LocalDate startDate = parseDate(queryDTO.getStartDate(), LocalDate.now().minusDays(30));
|
|
|
+ LocalDate endDate = parseDate(queryDTO.getEndDate(), LocalDate.now());
|
|
|
+
|
|
|
+ LambdaQueryWrapper<Shop> shopWrapper = new LambdaQueryWrapper<>();
|
|
|
+ if (queryDTO.getProvince() != null) {
|
|
|
+ shopWrapper.eq(Shop::getProvince, queryDTO.getProvince());
|
|
|
+ }
|
|
|
+ if (queryDTO.getCity() != null) {
|
|
|
+ shopWrapper.eq(Shop::getCity, queryDTO.getCity());
|
|
|
+ }
|
|
|
+ if (queryDTO.getShopId() != null) {
|
|
|
+ shopWrapper.eq(Shop::getId, queryDTO.getShopId());
|
|
|
+ }
|
|
|
+
|
|
|
+ List<Shop> shops = shopMapper.selectList(shopWrapper);
|
|
|
+
|
|
|
+ LambdaQueryWrapper<OrderItem> itemWrapper = new LambdaQueryWrapper<>();
|
|
|
+ itemWrapper.ge(OrderItem::getPayTime, startDate.atStartOfDay())
|
|
|
+ .le(OrderItem::getPayTime, endDate.atTime(LocalTime.MAX));
|
|
|
+
|
|
|
+ List<OrderItem> items = orderItemMapper.selectList(itemWrapper);
|
|
|
+
|
|
|
+ Map<Long, List<OrderItem>> shopItemMap = items.stream()
|
|
|
+ .filter(i -> i.getShopId() != null)
|
|
|
+ .collect(Collectors.groupingBy(OrderItem::getShopId));
|
|
|
+
|
|
|
+ List<ProfitStatVO> resultList = new ArrayList<>();
|
|
|
+ for (Shop shop : shops) {
|
|
|
+ ProfitStatVO vo = new ProfitStatVO();
|
|
|
+ vo.setShopId(shop.getId());
|
|
|
+ vo.setShopName(shop.getName());
|
|
|
+ vo.setProvince(shop.getProvince());
|
|
|
+ vo.setCity(shop.getCity());
|
|
|
+ vo.setDistrict(shop.getDistrict());
|
|
|
+
|
|
|
+ long deviceCount = deviceMapper.selectCount(
|
|
|
+ new LambdaQueryWrapper<Device>().eq(Device::getShopId, shop.getId())
|
|
|
+ );
|
|
|
+ vo.setDeviceCount((int) deviceCount);
|
|
|
+
|
|
|
+ List<OrderItem> shopItems = shopItemMap.getOrDefault(shop.getId(), Collections.emptyList());
|
|
|
+
|
|
|
+ BigDecimal salesAmount = shopItems.stream()
|
|
|
+ .map(i -> i.getTotalAmount() != null ? i.getTotalAmount() : BigDecimal.ZERO)
|
|
|
+ .reduce(BigDecimal.ZERO, BigDecimal::add);
|
|
|
+ vo.setSalesAmount(salesAmount.setScale(2, RoundingMode.HALF_UP));
|
|
|
+
|
|
|
+ BigDecimal costAmount = shopItems.stream()
|
|
|
+ .map(i -> i.getCostPrice() != null && i.getQuantity() != null
|
|
|
+ ? i.getCostPrice().multiply(BigDecimal.valueOf(i.getQuantity()))
|
|
|
+ : BigDecimal.ZERO)
|
|
|
+ .reduce(BigDecimal.ZERO, BigDecimal::add);
|
|
|
+ vo.setCostAmount(costAmount.setScale(2, RoundingMode.HALF_UP));
|
|
|
+
|
|
|
+ BigDecimal grossProfit = salesAmount.subtract(costAmount);
|
|
|
+ vo.setGrossProfit(grossProfit.setScale(2, RoundingMode.HALF_UP));
|
|
|
+
|
|
|
+ BigDecimal grossProfitRate = salesAmount.compareTo(BigDecimal.ZERO) > 0
|
|
|
+ ? grossProfit.divide(salesAmount, 4, RoundingMode.HALF_UP).multiply(new BigDecimal("100"))
|
|
|
+ : BigDecimal.ZERO;
|
|
|
+ vo.setGrossProfitRate(grossProfitRate.setScale(2, RoundingMode.HALF_UP));
|
|
|
+
|
|
|
+ vo.setRefundAmount(BigDecimal.ZERO);
|
|
|
+ vo.setRefundRate(BigDecimal.ZERO);
|
|
|
+ vo.setNetProfit(grossProfit);
|
|
|
+
|
|
|
+ BigDecimal netProfitRate = salesAmount.compareTo(BigDecimal.ZERO) > 0
|
|
|
+ ? grossProfit.divide(salesAmount, 4, RoundingMode.HALF_UP).multiply(new BigDecimal("100"))
|
|
|
+ : BigDecimal.ZERO;
|
|
|
+ vo.setNetProfitRate(netProfitRate.setScale(2, RoundingMode.HALF_UP));
|
|
|
+
|
|
|
+ BigDecimal deviceProfit = deviceCount > 0
|
|
|
+ ? grossProfit.divide(BigDecimal.valueOf(deviceCount), 2, RoundingMode.HALF_UP)
|
|
|
+ : BigDecimal.ZERO;
|
|
|
+ vo.setDeviceProfit(deviceProfit);
|
|
|
+
|
|
|
+ long days = ChronoUnit.DAYS.between(startDate, endDate) + 1;
|
|
|
+ BigDecimal dailyProfit = grossProfit.divide(BigDecimal.valueOf(days), 2, RoundingMode.HALF_UP);
|
|
|
+ vo.setDailyProfit(dailyProfit);
|
|
|
+
|
|
|
+ resultList.add(vo);
|
|
|
+ }
|
|
|
+
|
|
|
+ String sortBy = queryDTO.getSortBy() != null ? queryDTO.getSortBy() : "netProfit";
|
|
|
+ boolean asc = "asc".equalsIgnoreCase(queryDTO.getSortOrder());
|
|
|
+
|
|
|
+ Comparator<ProfitStatVO> comparator = null;
|
|
|
+ switch (sortBy) {
|
|
|
+ case "salesAmount":
|
|
|
+ comparator = Comparator.comparing(ProfitStatVO::getSalesAmount);
|
|
|
+ break;
|
|
|
+ case "grossProfit":
|
|
|
+ comparator = Comparator.comparing(ProfitStatVO::getGrossProfit);
|
|
|
+ break;
|
|
|
+ case "netProfit":
|
|
|
+ default:
|
|
|
+ comparator = Comparator.comparing(ProfitStatVO::getNetProfit);
|
|
|
+ break;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!asc) {
|
|
|
+ comparator = comparator.reversed();
|
|
|
+ }
|
|
|
+ resultList.sort(comparator);
|
|
|
+
|
|
|
+ Page<ProfitStatVO> page = new Page<>(queryDTO.getPage(), queryDTO.getPageSize());
|
|
|
+ int start = (queryDTO.getPage() - 1) * queryDTO.getPageSize();
|
|
|
+ int end = Math.min(start + queryDTO.getPageSize(), resultList.size());
|
|
|
+
|
|
|
+ page.setRecords(resultList.subList(start, end));
|
|
|
+ page.setTotal(resultList.size());
|
|
|
+
|
|
|
+ return page;
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public TrendDataVO getProfitTrend(StatisticsQueryDTO queryDTO) {
|
|
|
+ TrendDataVO trend = new TrendDataVO();
|
|
|
+
|
|
|
+ LocalDate startDate = parseDate(queryDTO.getStartDate(), LocalDate.now().minusDays(30));
|
|
|
+ LocalDate endDate = parseDate(queryDTO.getEndDate(), LocalDate.now());
|
|
|
+
|
|
|
+ LambdaQueryWrapper<OrderItem> wrapper = new LambdaQueryWrapper<>();
|
|
|
+ wrapper.ge(OrderItem::getPayTime, startDate.atStartOfDay())
|
|
|
+ .le(OrderItem::getPayTime, endDate.atTime(LocalTime.MAX));
|
|
|
+
|
|
|
+ if (queryDTO.getShopId() != null) {
|
|
|
+ wrapper.eq(OrderItem::getShopId, queryDTO.getShopId());
|
|
|
+ }
|
|
|
+
|
|
|
+ List<OrderItem> items = orderItemMapper.selectList(wrapper);
|
|
|
+
|
|
|
+ Map<String, BigDecimal> dateProfit = new HashMap<>();
|
|
|
+ for (OrderItem item : items) {
|
|
|
+ String date = item.getPayTime().toLocalDate().toString();
|
|
|
+ BigDecimal sales = item.getTotalAmount() != null ? item.getTotalAmount() : BigDecimal.ZERO;
|
|
|
+ BigDecimal cost = item.getCostPrice() != null && item.getQuantity() != null
|
|
|
+ ? item.getCostPrice().multiply(BigDecimal.valueOf(item.getQuantity()))
|
|
|
+ : BigDecimal.ZERO;
|
|
|
+ BigDecimal profit = sales.subtract(cost);
|
|
|
+ dateProfit.merge(date, profit, BigDecimal::add);
|
|
|
+ }
|
|
|
+
|
|
|
+ List<String> dates = new ArrayList<>();
|
|
|
+ List<BigDecimal> data = new ArrayList<>();
|
|
|
+
|
|
|
+ long days = ChronoUnit.DAYS.between(startDate, endDate) + 1;
|
|
|
+ for (int i = 0; i < days; i++) {
|
|
|
+ LocalDate date = startDate.plusDays(i);
|
|
|
+ dates.add(date.format(DateTimeFormatter.ofPattern("MM-dd")));
|
|
|
+ data.add(dateProfit.getOrDefault(date.toString(), BigDecimal.ZERO).setScale(2, RoundingMode.HALF_UP));
|
|
|
+ }
|
|
|
+
|
|
|
+ trend.setDates(dates);
|
|
|
+
|
|
|
+ SeriesDataVO series = new SeriesDataVO();
|
|
|
+ series.setName("利润");
|
|
|
+ series.setData(data);
|
|
|
+ trend.setSeries(Collections.singletonList(series));
|
|
|
+
|
|
|
+ return trend;
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public List<ProfitStatVO> getProfitWarning(StatisticsQueryDTO queryDTO) {
|
|
|
+ StatisticsQueryDTO listQuery = new StatisticsQueryDTO();
|
|
|
+ listQuery.setStartDate(queryDTO.getStartDate());
|
|
|
+ listQuery.setEndDate(queryDTO.getEndDate());
|
|
|
+ listQuery.setProvince(queryDTO.getProvince());
|
|
|
+ listQuery.setCity(queryDTO.getCity());
|
|
|
+ listQuery.setSortBy("netProfit");
|
|
|
+ listQuery.setSortOrder("asc");
|
|
|
+ listQuery.setPage(1);
|
|
|
+ listQuery.setPageSize(20);
|
|
|
+
|
|
|
+ IPage<ProfitStatVO> page = getProfitList(listQuery);
|
|
|
+
|
|
|
+ String type = queryDTO.getType() != null ? queryDTO.getType() : "low";
|
|
|
+ BigDecimal threshold = queryDTO.getThreshold() != null
|
|
|
+ ? queryDTO.getThreshold()
|
|
|
+ : new BigDecimal("10");
|
|
|
+
|
|
|
+ return page.getRecords().stream()
|
|
|
+ .filter(vo -> {
|
|
|
+ if ("negative".equals(type)) {
|
|
|
+ return vo.getNetProfit().compareTo(BigDecimal.ZERO) < 0;
|
|
|
+ } else {
|
|
|
+ return vo.getNetProfitRate().compareTo(threshold) < 0;
|
|
|
+ }
|
|
|
+ })
|
|
|
+ .collect(Collectors.toList());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public Map<String, Object> getRepurchaseOverview(StatisticsQueryDTO queryDTO) {
|
|
|
+ Map<String, Object> result = new HashMap<>();
|
|
|
+
|
|
|
+ LocalDate startDate = parseDate(queryDTO.getStartDate(), LocalDate.now().minusDays(30));
|
|
|
+ LocalDate endDate = parseDate(queryDTO.getEndDate(), LocalDate.now());
|
|
|
+
|
|
|
+ LambdaQueryWrapper<Order> orderWrapper = new LambdaQueryWrapper<>();
|
|
|
+ orderWrapper.eq(Order::getPayStatus, OrderConstants.PAY_STATUS_PAID)
|
|
|
+ .ge(Order::getPayTime, startDate.atStartOfDay())
|
|
|
+ .le(Order::getPayTime, endDate.atTime(LocalTime.MAX));
|
|
|
+
|
|
|
+ if (queryDTO.getShopId() != null) {
|
|
|
+ orderWrapper.eq(Order::getShopId, queryDTO.getShopId());
|
|
|
+ }
|
|
|
+
|
|
|
+ List<Order> orders = orderMapper.selectList(orderWrapper);
|
|
|
+
|
|
|
+ Map<Long, List<Order>> userOrderMap = orders.stream()
|
|
|
+ .filter(o -> o.getUserId() != null)
|
|
|
+ .collect(Collectors.groupingBy(Order::getUserId));
|
|
|
+
|
|
|
+ long totalUsers = userOrderMap.size();
|
|
|
+ long newUsers = userOrderMap.values().stream()
|
|
|
+ .filter(orderList -> orderList.size() == 1)
|
|
|
+ .count();
|
|
|
+ long repurchaseUsers = userOrderMap.values().stream()
|
|
|
+ .filter(orderList -> orderList.size() >= 2)
|
|
|
+ .count();
|
|
|
+
|
|
|
+ result.put("totalUsers", totalUsers);
|
|
|
+ result.put("newUsers", newUsers);
|
|
|
+ result.put("repurchaseUsers", repurchaseUsers);
|
|
|
+
|
|
|
+ BigDecimal repurchaseRate = totalUsers > 0
|
|
|
+ ? BigDecimal.valueOf(repurchaseUsers * 100.0 / totalUsers)
|
|
|
+ : BigDecimal.ZERO;
|
|
|
+ result.put("repurchaseRate", repurchaseRate.setScale(2, RoundingMode.HALF_UP));
|
|
|
+
|
|
|
+ BigDecimal totalSales = orders.stream()
|
|
|
+ .map(o -> o.getTotalAmount() != null ? o.getTotalAmount() : BigDecimal.ZERO)
|
|
|
+ .reduce(BigDecimal.ZERO, BigDecimal::add);
|
|
|
+
|
|
|
+ BigDecimal avgOrderAmount = orders.size() > 0
|
|
|
+ ? totalSales.divide(BigDecimal.valueOf(orders.size()), 2, RoundingMode.HALF_UP)
|
|
|
+ : BigDecimal.ZERO;
|
|
|
+ result.put("avgOrderAmount", avgOrderAmount);
|
|
|
+
|
|
|
+ BigDecimal avgPurchaseCount = totalUsers > 0
|
|
|
+ ? BigDecimal.valueOf(orders.size()).divide(BigDecimal.valueOf(totalUsers), 2, RoundingMode.HALF_UP)
|
|
|
+ : BigDecimal.ZERO;
|
|
|
+ result.put("avgPurchaseCount", avgPurchaseCount);
|
|
|
+
|
|
|
+ BigDecimal ltv = avgPurchaseCount.multiply(avgOrderAmount);
|
|
|
+ result.put("ltv", ltv.setScale(2, RoundingMode.HALF_UP));
|
|
|
+
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public Map<String, Object> getRepurchaseDistribution(StatisticsQueryDTO queryDTO) {
|
|
|
+ Map<String, Object> result = new HashMap<>();
|
|
|
+
|
|
|
+ LocalDate startDate = parseDate(queryDTO.getStartDate(), LocalDate.now().minusDays(30));
|
|
|
+ LocalDate endDate = parseDate(queryDTO.getEndDate(), LocalDate.now());
|
|
|
+ String type = queryDTO.getType() != null ? queryDTO.getType() : "layer";
|
|
|
+
|
|
|
+ LambdaQueryWrapper<Order> orderWrapper = new LambdaQueryWrapper<>();
|
|
|
+ orderWrapper.eq(Order::getPayStatus, OrderConstants.PAY_STATUS_PAID)
|
|
|
+ .ge(Order::getPayTime, startDate.atStartOfDay())
|
|
|
+ .le(Order::getPayTime, endDate.atTime(LocalTime.MAX));
|
|
|
+
|
|
|
+ if (queryDTO.getShopId() != null) {
|
|
|
+ orderWrapper.eq(Order::getShopId, queryDTO.getShopId());
|
|
|
+ }
|
|
|
+
|
|
|
+ List<Order> orders = orderMapper.selectList(orderWrapper);
|
|
|
+
|
|
|
+ Map<Long, List<Order>> userOrderMap = orders.stream()
|
|
|
+ .filter(o -> o.getUserId() != null)
|
|
|
+ .collect(Collectors.groupingBy(Order::getUserId));
|
|
|
+
|
|
|
+ if ("interval".equals(type)) {
|
|
|
+ Map<String, Integer> intervalDistribution = new LinkedHashMap<>();
|
|
|
+ intervalDistribution.put("1天内", 0);
|
|
|
+ intervalDistribution.put("1-3天", 0);
|
|
|
+ intervalDistribution.put("3-7天", 0);
|
|
|
+ intervalDistribution.put("7-14天", 0);
|
|
|
+ intervalDistribution.put("14-30天", 0);
|
|
|
+ intervalDistribution.put("30天以上", 0);
|
|
|
+
|
|
|
+ for (List<Order> userOrders : userOrderMap.values()) {
|
|
|
+ if (userOrders.size() >= 2) {
|
|
|
+ userOrders.sort(Comparator.comparing(Order::getPayTime));
|
|
|
+ for (int i = 1; i < userOrders.size(); i++) {
|
|
|
+ long days = ChronoUnit.DAYS.between(
|
|
|
+ userOrders.get(i-1).getPayTime().toLocalDate(),
|
|
|
+ userOrders.get(i).getPayTime().toLocalDate()
|
|
|
+ );
|
|
|
+
|
|
|
+ if (days < 1) intervalDistribution.merge("1天内", 1, Integer::sum);
|
|
|
+ else if (days < 3) intervalDistribution.merge("1-3天", 1, Integer::sum);
|
|
|
+ else if (days < 7) intervalDistribution.merge("3-7天", 1, Integer::sum);
|
|
|
+ else if (days < 14) intervalDistribution.merge("7-14天", 1, Integer::sum);
|
|
|
+ else if (days < 30) intervalDistribution.merge("14-30天", 1, Integer::sum);
|
|
|
+ else intervalDistribution.merge("30天以上", 1, Integer::sum);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ result.put("distribution", intervalDistribution);
|
|
|
+ } else {
|
|
|
+ Map<String, Integer> layerDistribution = new LinkedHashMap<>();
|
|
|
+
|
|
|
+ int newUserCount = 0;
|
|
|
+ int activeUserCount = 0;
|
|
|
+ int loyalUserCount = 0;
|
|
|
+ int churnUserCount = 0;
|
|
|
+
|
|
|
+ LocalDate now = LocalDate.now();
|
|
|
+
|
|
|
+ for (Map.Entry<Long, List<Order>> entry : userOrderMap.entrySet()) {
|
|
|
+ List<Order> userOrders = entry.getValue();
|
|
|
+ int totalOrders = userOrders.size();
|
|
|
+
|
|
|
+ LocalDate lastOrderDate = userOrders.stream()
|
|
|
+ .map(o -> o.getPayTime().toLocalDate())
|
|
|
+ .max(LocalDate::compareTo)
|
|
|
+ .orElse(now);
|
|
|
+
|
|
|
+ long daysSinceLastOrder = ChronoUnit.DAYS.between(lastOrderDate, now);
|
|
|
+
|
|
|
+ if (totalOrders == 1) {
|
|
|
+ newUserCount++;
|
|
|
+ } else if (totalOrders >= 5) {
|
|
|
+ loyalUserCount++;
|
|
|
+ } else if (daysSinceLastOrder > 60) {
|
|
|
+ churnUserCount++;
|
|
|
+ } else {
|
|
|
+ activeUserCount++;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ layerDistribution.put("新用户", newUserCount);
|
|
|
+ layerDistribution.put("活跃用户", activeUserCount);
|
|
|
+ layerDistribution.put("忠诚用户", loyalUserCount);
|
|
|
+ layerDistribution.put("流失用户", churnUserCount);
|
|
|
+
|
|
|
+ result.put("distribution", layerDistribution);
|
|
|
+ }
|
|
|
+
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public TrendDataVO getRepurchaseTrend(StatisticsQueryDTO queryDTO) {
|
|
|
+ TrendDataVO trend = new TrendDataVO();
|
|
|
+
|
|
|
+ LocalDate startDate = parseDate(queryDTO.getStartDate(), LocalDate.now().minusDays(30));
|
|
|
+ LocalDate endDate = parseDate(queryDTO.getEndDate(), LocalDate.now());
|
|
|
+
|
|
|
+ List<String> dates = new ArrayList<>();
|
|
|
+ List<BigDecimal> data = new ArrayList<>();
|
|
|
+
|
|
|
+ long days = ChronoUnit.DAYS.between(startDate, endDate) + 1;
|
|
|
+ for (int i = 0; i < days; i++) {
|
|
|
+ LocalDate date = startDate.plusDays(i);
|
|
|
+ dates.add(date.format(DateTimeFormatter.ofPattern("MM-dd")));
|
|
|
+
|
|
|
+ LocalDateTime dayStart = date.atStartOfDay();
|
|
|
+ LocalDateTime dayEnd = date.atTime(LocalTime.MAX);
|
|
|
+
|
|
|
+ LambdaQueryWrapper<Order> dayWrapper = new LambdaQueryWrapper<>();
|
|
|
+ dayWrapper.eq(Order::getPayStatus, OrderConstants.PAY_STATUS_PAID)
|
|
|
+ .ge(Order::getPayTime, dayStart)
|
|
|
+ .le(Order::getPayTime, dayEnd);
|
|
|
+
|
|
|
+ if (queryDTO.getShopId() != null) {
|
|
|
+ dayWrapper.eq(Order::getShopId, queryDTO.getShopId());
|
|
|
+ }
|
|
|
+
|
|
|
+ List<Order> dayOrders = orderMapper.selectList(dayWrapper);
|
|
|
+
|
|
|
+ Map<Long, Long> userOrderCount = dayOrders.stream()
|
|
|
+ .filter(o -> o.getUserId() != null)
|
|
|
+ .collect(Collectors.groupingBy(Order::getUserId, Collectors.counting()));
|
|
|
+
|
|
|
+ long dayRepurchaseUsers = userOrderCount.values().stream()
|
|
|
+ .filter(count -> count >= 2)
|
|
|
+ .count();
|
|
|
+
|
|
|
+ BigDecimal repurchaseRate = userOrderCount.size() > 0
|
|
|
+ ? BigDecimal.valueOf(dayRepurchaseUsers * 100.0 / userOrderCount.size())
|
|
|
+ : BigDecimal.ZERO;
|
|
|
+
|
|
|
+ data.add(repurchaseRate.setScale(2, RoundingMode.HALF_UP));
|
|
|
+ }
|
|
|
+
|
|
|
+ trend.setDates(dates);
|
|
|
+
|
|
|
+ SeriesDataVO series = new SeriesDataVO();
|
|
|
+ series.setName("复购率(%)");
|
|
|
+ series.setData(data);
|
|
|
+ trend.setSeries(Collections.singletonList(series));
|
|
|
+
|
|
|
+ return trend;
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public IPage<RepurchaseStatVO> getRepurchaseUsers(StatisticsQueryDTO queryDTO) {
|
|
|
+ queryDTO.validate();
|
|
|
+
|
|
|
+ LocalDate startDate = parseDate(queryDTO.getStartDate(), LocalDate.now().minusDays(30));
|
|
|
+ LocalDate endDate = parseDate(queryDTO.getEndDate(), LocalDate.now());
|
|
|
+
|
|
|
+ LambdaQueryWrapper<Order> orderWrapper = new LambdaQueryWrapper<>();
|
|
|
+ orderWrapper.eq(Order::getPayStatus, OrderConstants.PAY_STATUS_PAID)
|
|
|
+ .ge(Order::getPayTime, startDate.atStartOfDay())
|
|
|
+ .le(Order::getPayTime, endDate.atTime(LocalTime.MAX));
|
|
|
+
|
|
|
+ if (queryDTO.getShopId() != null) {
|
|
|
+ orderWrapper.eq(Order::getShopId, queryDTO.getShopId());
|
|
|
+ }
|
|
|
+
|
|
|
+ List<Order> orders = orderMapper.selectList(orderWrapper);
|
|
|
+
|
|
|
+ Map<Long, List<Order>> userOrderMap = orders.stream()
|
|
|
+ .filter(o -> o.getUserId() != null)
|
|
|
+ .collect(Collectors.groupingBy(Order::getUserId));
|
|
|
+
|
|
|
+ List<RepurchaseStatVO> resultList = new ArrayList<>();
|
|
|
+
|
|
|
+ for (Map.Entry<Long, List<Order>> entry : userOrderMap.entrySet()) {
|
|
|
+ Long userId = entry.getKey();
|
|
|
+ List<Order> userOrders = entry.getValue();
|
|
|
+
|
|
|
+ if (queryDTO.getMinOrderCount() != null && userOrders.size() < queryDTO.getMinOrderCount()) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ RepurchaseStatVO vo = new RepurchaseStatVO();
|
|
|
+ vo.setUserId(userId);
|
|
|
+ vo.setOrderCount(userOrders.size());
|
|
|
+
|
|
|
+ User user = userMapper.selectById(userId);
|
|
|
+ if (user != null) {
|
|
|
+ vo.setNickname(user.getNickname());
|
|
|
+ vo.setPhone(user.getPhone());
|
|
|
+ }
|
|
|
+
|
|
|
+ userOrders.sort(Comparator.comparing(Order::getPayTime));
|
|
|
+
|
|
|
+ vo.setFirstOrderDate(userOrders.get(0).getPayTime().toLocalDate());
|
|
|
+ vo.setLastOrderDate(userOrders.get(userOrders.size() - 1).getPayTime().toLocalDate());
|
|
|
+
|
|
|
+ BigDecimal totalAmount = userOrders.stream()
|
|
|
+ .map(o -> o.getTotalAmount() != null ? o.getTotalAmount() : BigDecimal.ZERO)
|
|
|
+ .reduce(BigDecimal.ZERO, BigDecimal::add);
|
|
|
+ vo.setTotalAmount(totalAmount.setScale(2, RoundingMode.HALF_UP));
|
|
|
+
|
|
|
+ BigDecimal avgOrderAmount = userOrders.size() > 0
|
|
|
+ ? totalAmount.divide(BigDecimal.valueOf(userOrders.size()), 2, RoundingMode.HALF_UP)
|
|
|
+ : BigDecimal.ZERO;
|
|
|
+ vo.setAvgOrderAmount(avgOrderAmount);
|
|
|
+
|
|
|
+ if (userOrders.size() >= 2) {
|
|
|
+ long avgDays = 0;
|
|
|
+ for (int i = 1; i < userOrders.size(); i++) {
|
|
|
+ avgDays += ChronoUnit.DAYS.between(
|
|
|
+ userOrders.get(i-1).getPayTime().toLocalDate(),
|
|
|
+ userOrders.get(i).getPayTime().toLocalDate()
|
|
|
+ );
|
|
|
+ }
|
|
|
+ vo.setRepurchaseDays((int) (avgDays / (userOrders.size() - 1)));
|
|
|
+ } else {
|
|
|
+ vo.setRepurchaseDays(0);
|
|
|
+ }
|
|
|
+
|
|
|
+ String userLayer = determineUserLayer(userOrders);
|
|
|
+ vo.setUserLayer(userLayer);
|
|
|
+ vo.setUserLayerLabel(getUserLayerLabel(userLayer));
|
|
|
+
|
|
|
+ resultList.add(vo);
|
|
|
+ }
|
|
|
+
|
|
|
+ if (queryDTO.getUserLayer() != null) {
|
|
|
+ resultList = resultList.stream()
|
|
|
+ .filter(vo -> queryDTO.getUserLayer().equals(vo.getUserLayer()))
|
|
|
+ .collect(Collectors.toList());
|
|
|
+ }
|
|
|
+
|
|
|
+ String sortBy = queryDTO.getSortBy() != null ? queryDTO.getSortBy() : "totalAmount";
|
|
|
+ boolean asc = "asc".equalsIgnoreCase(queryDTO.getSortOrder());
|
|
|
+
|
|
|
+ Comparator<RepurchaseStatVO> comparator = null;
|
|
|
+ switch (sortBy) {
|
|
|
+ case "orderCount":
|
|
|
+ comparator = Comparator.comparing(RepurchaseStatVO::getOrderCount);
|
|
|
+ break;
|
|
|
+ case "avgOrderAmount":
|
|
|
+ comparator = Comparator.comparing(RepurchaseStatVO::getAvgOrderAmount);
|
|
|
+ break;
|
|
|
+ case "totalAmount":
|
|
|
+ default:
|
|
|
+ comparator = Comparator.comparing(RepurchaseStatVO::getTotalAmount);
|
|
|
+ break;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!asc) {
|
|
|
+ comparator = comparator.reversed();
|
|
|
+ }
|
|
|
+ resultList.sort(comparator);
|
|
|
+
|
|
|
+ Page<RepurchaseStatVO> page = new Page<>(queryDTO.getPage(), queryDTO.getPageSize());
|
|
|
+ int start = (queryDTO.getPage() - 1) * queryDTO.getPageSize();
|
|
|
+ int end = Math.min(start + queryDTO.getPageSize(), resultList.size());
|
|
|
+
|
|
|
+ page.setRecords(resultList.subList(start, end));
|
|
|
+ page.setTotal(resultList.size());
|
|
|
+
|
|
|
+ return page;
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public byte[] exportStatistics(StatisticsQueryDTO queryDTO) {
|
|
|
+ return new byte[0];
|
|
|
+ }
|
|
|
+
|
|
|
+ private LocalDate parseDate(String dateStr, LocalDate defaultDate) {
|
|
|
+ if (dateStr == null || dateStr.isEmpty()) {
|
|
|
+ return defaultDate;
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ return LocalDate.parse(dateStr);
|
|
|
+ } catch (Exception e) {
|
|
|
+ return defaultDate;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private String determineUserLayer(List<Order> userOrders) {
|
|
|
+ int totalOrders = userOrders.size();
|
|
|
+ LocalDate now = LocalDate.now();
|
|
|
+
|
|
|
+ LocalDate lastOrderDate = userOrders.stream()
|
|
|
+ .map(o -> o.getPayTime().toLocalDate())
|
|
|
+ .max(LocalDate::compareTo)
|
|
|
+ .orElse(now);
|
|
|
+
|
|
|
+ long daysSinceLastOrder = ChronoUnit.DAYS.between(lastOrderDate, now);
|
|
|
+
|
|
|
+ if (totalOrders == 1) {
|
|
|
+ return "new";
|
|
|
+ } else if (totalOrders >= 5) {
|
|
|
+ return "loyal";
|
|
|
+ } else if (daysSinceLastOrder > 60) {
|
|
|
+ return "churn";
|
|
|
+ } else {
|
|
|
+ return "active";
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private String getUserLayerLabel(String layer) {
|
|
|
+ switch (layer) {
|
|
|
+ case "new": return "新用户";
|
|
|
+ case "active": return "活跃用户";
|
|
|
+ case "loyal": return "忠诚用户";
|
|
|
+ case "churn": return "流失用户";
|
|
|
+ default: return "未知";
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|