fix: 加盟商看板用户统计改造与空值保护
1. 新增用户改为统计窗内首次充电成功口径(franchiseeUserFirstChargedDays)
2. 删除废弃的 FranchiseeNewUserStat
3. 新增用户统计补充 siteIds 空值保护, 修复充电用户小于新增用户的倒挂问题
| | |
| | | chargingUsersByDay.computeIfAbsent(agg.getStatDay(), key -> new HashSet<>()).add(agg.getAppUserId()); |
| | | } |
| | | Map<LocalDate, Long> newUsersByDay = new LinkedHashMap<>(); |
| | | if (range != null && range[0] != null && range[1] != null) { |
| | | List<FranchiseeNewUserStat> newUserStats = chargingOrderMapper.franchiseeNewUserStats(range[0], range[1]); |
| | | if (newUserStats != null) { |
| | | for (FranchiseeNewUserStat stat : newUserStats) { |
| | | if (stat != null && stat.getStatDay() != null && stat.getUserCount() != null) { |
| | | newUsersByDay.put(stat.getStatDay(), stat.getUserCount()); |
| | | if (siteIds != null && !siteIds.isEmpty() && range != null && range[0] != null && range[1] != null) { |
| | | List<FranchiseeUserFirstCharge> firstCharges = chargingOrderMapper.franchiseeUserFirstChargedDays(siteIds, range[0], range[1]); |
| | | if (firstCharges != null) { |
| | | for (FranchiseeUserFirstCharge fc : firstCharges) { |
| | | if (fc != null && fc.getAppUserId() != null && fc.getFirstChargedDay() != null) { |
| | | newUsersByDay.merge(fc.getFirstChargedDay(), 1L, Long::sum); |
| | | } |
| | | } |
| | | } |
| New file |
| | |
| | | package com.ruoyi.order.dto; |
| | | |
| | | import lombok.Data; |
| | | |
| | | import java.time.LocalDate; |
| | | |
| | | /** |
| | | * 加盟商站点范围内"新增用户"识别行: |
| | | * 统计时间窗内每个用户首次充电成功订单日(MIN(DATE(end_time)))。 |
| | | * 供加盟商看板"新增用户"按首次充电成功日统计使用,与"充电用户"(加盟商站点口径)同过滤条件。 |
| | | */ |
| | | @Data |
| | | public class FranchiseeUserFirstCharge { |
| | | |
| | | /** 用户 id */ |
| | | private Long appUserId; |
| | | |
| | | /** 该用户时间窗内首次充电成功日 */ |
| | | private LocalDate firstChargedDay; |
| | | } |
| | |
| | | @Param("end") LocalDateTime end); |
| | | |
| | | /** |
| | | * 平台新增用户按注册日聚合(全平台口径,t_app_user.create_time,跨库)。 |
| | | * 加盟商站点范围内、统计时间窗内每用户首次充电成功日(仅 status=5 已结束且已支付订单)。 |
| | | */ |
| | | List<FranchiseeNewUserStat> franchiseeNewUserStats(@Param("start") LocalDateTime start, |
| | | @Param("end") LocalDateTime end); |
| | | List<FranchiseeUserFirstCharge> franchiseeUserFirstChargedDays(@Param("siteIds") List<Integer> siteIds, |
| | | @Param("start") LocalDateTime start, |
| | | @Param("end") LocalDateTime end); |
| | | |
| | | /** |
| | | * 流量来源统计(按 tripartite_platform_name 分组,仅 status=5 已结束订单)。 |
| | |
| | | package com.ruoyi.order.support; |
| | | |
| | | import com.ruoyi.order.api.model.TChargingOrder; |
| | | |
| | | import java.time.LocalDate; |
| | | import java.time.LocalDateTime; |
| | | import java.util.ArrayList; |
| | | import java.util.Arrays; |
| | | import java.util.Collections; |
| | | import java.util.HashMap; |
| | | import java.util.HashSet; |
| | | import java.util.LinkedHashMap; |
| | | import java.util.List; |
| | |
| | | } |
| | | |
| | | /** |
| | | * 旧入口:由订单列表推导"每日充电用户集合"后计算。 |
| | | * 新增用户为全平台口径(platformNewUsersByDay 为 t_app_user 注册日聚合),与加盟商绑定无关。 |
| | | */ |
| | | public static Result calculate(List<TChargingOrder> orders, |
| | | Map<LocalDate, Long> platformNewUsersByDay, |
| | | LocalDateTime start, |
| | | LocalDateTime end) { |
| | | List<TChargingOrder> safeOrders = orders == null ? Collections.emptyList() : orders; |
| | | Map<LocalDate, Set<Long>> chargingUsersByDay = new HashMap<>(); |
| | | for (TChargingOrder order : safeOrders) { |
| | | if (order == null || order.getAppUserId() == null || !inRange(order.getEndTime(), start, end)) { |
| | | continue; |
| | | } |
| | | chargingUsersByDay.computeIfAbsent(order.getEndTime().toLocalDate(), key -> new HashSet<>()) |
| | | .add(order.getAppUserId()); |
| | | } |
| | | return calculateByUserDays(chargingUsersByDay, platformNewUsersByDay, start, end); |
| | | } |
| | | |
| | | /** |
| | | * 加盟商看板入口: |
| | | * 充电用户由每日去重充电用户集合得到(t_charging_order GROUP BY app_user_id, DATE(end_time), |
| | | * 避免全量订单拉回 JVM,仅加盟商站点范围); |
| | | * 新增用户为全平台口径(t_app_user.create_time 按日聚合),与加盟商站点/绑定无关。 |
| | | * 避免全量订单拉回 JVM,仅加盟商站点范围,仅 status=5 已结束且已支付订单); |
| | | * 新增用户为加盟商站点范围内"统计窗内首次充电成功"的用户(每用户按首次充电成功日计 1, |
| | | * 由 franchiseeUserFirstChargedDays 的 MIN(DATE(end_time)) 推导)。 |
| | | */ |
| | | public static Result calculateByUserDays(Map<LocalDate, Set<Long>> chargingUsersByDay, |
| | | Map<LocalDate, Long> platformNewUsersByDay, |
| | | Map<LocalDate, Long> firstChargedUsersByDay, |
| | | LocalDateTime start, |
| | | LocalDateTime end) { |
| | | Map<LocalDate, Set<Long>> safeByDay = chargingUsersByDay == null ? Collections.emptyMap() : chargingUsersByDay; |
| | | Map<LocalDate, Long> safeNewByDay = platformNewUsersByDay == null ? Collections.emptyMap() : platformNewUsersByDay; |
| | | Map<LocalDate, Long> safeNewByDay = firstChargedUsersByDay == null ? Collections.emptyMap() : firstChargedUsersByDay; |
| | | |
| | | Set<Long> chargingUsers = new HashSet<>(); |
| | | safeByDay.values().forEach(chargingUsers::addAll); |
| | |
| | | return day != null |
| | | && (start == null || !day.isBefore(start.toLocalDate())) |
| | | && (end == null || day.isBefore(end.toLocalDate())); |
| | | } |
| | | |
| | | private static boolean inRange(LocalDateTime time, LocalDateTime start, LocalDateTime end) { |
| | | return time != null |
| | | && (start == null || !time.isBefore(start)) |
| | | && (end == null || time.isBefore(end)); |
| | | } |
| | | |
| | | private static Map<String, Object> series(String name, List<Long> data) { |
| | |
| | | GROUP BY 1, 2, 3 |
| | | </select> |
| | | |
| | | <!-- 平台新增用户按注册日聚合(全平台口径,跨库 t_app_user;GROUP BY 序号防 Calcite 崩溃) --> |
| | | <select id="franchiseeNewUserStats" resultType="com.ruoyi.order.dto.FranchiseeNewUserStat"> |
| | | SELECT DATE(create_time) AS statDay, |
| | | COUNT(*) AS userCount |
| | | FROM charging_pile_account.t_app_user |
| | | <!-- 加盟商站点"新增用户"识别:统计窗内每用户首次充电成功日(MIN(DATE(end_time)),与 franchiseeUserAggs 同过滤条件) --> |
| | | <select id="franchiseeUserFirstChargedDays" resultType="com.ruoyi.order.dto.FranchiseeUserFirstCharge"> |
| | | SELECT app_user_id AS appUserId, |
| | | MIN(DATE(end_time)) AS firstChargedDay |
| | | FROM t_charging_order |
| | | WHERE del_flag = 0 |
| | | AND create_time >= #{start} |
| | | AND create_time < #{end} |
| | | GROUP BY 1 |
| | | ORDER BY 1 |
| | | AND recharge_payment_status = 2 |
| | | AND status = 5 |
| | | <if test="siteIds != null and siteIds.size() > 0"> |
| | | AND site_id IN |
| | | <foreach collection="siteIds" item="siteId" open="(" separator="," close=")"> |
| | | #{siteId} |
| | | </foreach> |
| | | </if> |
| | | AND end_time >= #{start} |
| | | AND end_time < #{end} |
| | | GROUP BY app_user_id |
| | | </select> |
| | | |
| | | <!-- 流量来源统计(仅 status=5 已结束订单,空值归"明星充电"由服务端处理) --> |
| | |
| | | package com.ruoyi.order.support; |
| | | |
| | | import com.ruoyi.order.api.model.TChargingOrder; |
| | | import org.junit.jupiter.api.Test; |
| | | |
| | | import java.time.LocalDate; |
| | | import java.time.LocalDateTime; |
| | | import java.util.Arrays; |
| | | import java.util.Collections; |
| | | import java.util.HashSet; |
| | | import java.util.LinkedHashMap; |
| | | import java.util.List; |
| | | import java.util.Map; |
| | | import java.util.Set; |
| | | |
| | | import static org.junit.jupiter.api.Assertions.assertEquals; |
| | | |
| | |
| | | private static final LocalDateTime END = LocalDateTime.of(2026, 7, 4, 0, 0); |
| | | |
| | | @Test |
| | | void countsDistinctChargingUsersAndSumsPlatformNewUsersByRegistrationDay() { |
| | | List<TChargingOrder> orders = Arrays.asList( |
| | | order(1L, LocalDateTime.of(2026, 7, 1, 8, 0)), |
| | | order(1L, LocalDateTime.of(2026, 7, 1, 18, 0)), |
| | | order(1L, LocalDateTime.of(2026, 7, 2, 8, 0)), |
| | | order(2L, LocalDateTime.of(2026, 7, 2, 9, 0)) |
| | | ); |
| | | void countsDistinctChargingUsersAndNewUsersByFirstChargeDay() { |
| | | Map<LocalDate, Set<Long>> chargingUsersByDay = new LinkedHashMap<>(); |
| | | chargingUsersByDay.put(LocalDate.of(2026, 7, 1), new HashSet<>(Arrays.asList(1L, 3L))); |
| | | chargingUsersByDay.put(LocalDate.of(2026, 7, 2), new HashSet<>(Arrays.asList(1L, 2L))); |
| | | |
| | | Map<LocalDate, Long> newUsersByDay = new LinkedHashMap<>(); |
| | | newUsersByDay.put(LocalDate.of(2026, 7, 1), 3L); |
| | | newUsersByDay.put(LocalDate.of(2026, 7, 2), 2L); |
| | | newUsersByDay.put(LocalDate.of(2026, 7, 1), 2L); // 用户1、3 时间窗内首次充电 |
| | | newUsersByDay.put(LocalDate.of(2026, 7, 2), 1L); // 用户2 时间窗内首次充电 |
| | | |
| | | FranchiseeUserStatistics.Result result = FranchiseeUserStatistics.calculate(orders, newUsersByDay, START, END); |
| | | FranchiseeUserStatistics.Result result = FranchiseeUserStatistics.calculateByUserDays( |
| | | chargingUsersByDay, newUsersByDay, START, END); |
| | | |
| | | assertEquals(2L, result.getSummary().get("chargeUserCount")); |
| | | assertEquals(5L, result.getSummary().get("newUserCount")); |
| | | assertEquals(3L, result.getSummary().get("chargeUserCount")); |
| | | assertEquals(3L, result.getSummary().get("newUserCount")); |
| | | assertEquals(Arrays.asList("07-01", "07-02", "07-03"), result.getTrend().get("categories")); |
| | | assertEquals(Arrays.asList(1L, 2L, 0L), seriesData(result.getTrend(), 0)); |
| | | assertEquals(Arrays.asList(3L, 2L, 0L), seriesData(result.getTrend(), 1)); |
| | | assertEquals(Arrays.asList(2L, 2L, 0L), seriesData(result.getTrend(), 0)); |
| | | assertEquals(Arrays.asList(2L, 1L, 0L), seriesData(result.getTrend(), 1)); |
| | | } |
| | | |
| | | @Test |
| | | void ignoresPlatformRegistrationsOutsideTheRange() { |
| | | void ignoresFirstChargesOutsideTheRange() { |
| | | Map<LocalDate, Long> newUsersByDay = new LinkedHashMap<>(); |
| | | newUsersByDay.put(LocalDate.of(2026, 6, 30), 99L); // 区间外,不计入 |
| | | newUsersByDay.put(LocalDate.of(2026, 7, 1), 1L); |
| | |
| | | |
| | | @Test |
| | | void returnsZeroFilledSeriesWhenThereIsNoActivity() { |
| | | FranchiseeUserStatistics.Result result = FranchiseeUserStatistics.calculate( |
| | | Collections.emptyList(), Collections.emptyMap(), START, END); |
| | | FranchiseeUserStatistics.Result result = FranchiseeUserStatistics.calculateByUserDays( |
| | | Collections.emptyMap(), Collections.emptyMap(), START, END); |
| | | |
| | | assertEquals(0L, result.getSummary().get("chargeUserCount")); |
| | | assertEquals(0L, result.getSummary().get("newUserCount")); |
| | |
| | | private List<Long> seriesData(Map<String, Object> trend, int index) { |
| | | List<Map<String, Object>> series = (List<Map<String, Object>>) trend.get("series"); |
| | | return (List<Long>) series.get(index).get("data"); |
| | | } |
| | | |
| | | private TChargingOrder order(Long appUserId, LocalDateTime endTime) { |
| | | TChargingOrder order = new TChargingOrder(); |
| | | order.setAppUserId(appUserId); |
| | | order.setEndTime(endTime); |
| | | return order; |
| | | } |
| | | } |