puzhibing
2026-08-18 0cc47a51f964524ffef0d5d9b87a0f87bb465870
fix: 加盟商看板用户统计改造与空值保护

1. 新增用户改为统计窗内首次充电成功口径(franchiseeUserFirstChargedDays)
2. 删除废弃的 FranchiseeNewUserStat
3. 新增用户统计补充 siteIds 空值保护, 修复充电用户小于新增用户的倒挂问题
1个文件已删除
1个文件已添加
5个文件已修改
164 ■■■■■ 已修改文件
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/controller/TChargingOrderController.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/dto/FranchiseeNewUserStat.java 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/dto/FranchiseeUserFirstCharge.java 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/mapper/TChargingOrderMapper.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/support/FranchiseeUserStatistics.java 38 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-order/src/main/resources/mapper/order/TChargingOrderMapper.xml 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/support/FranchiseeUserStatisticsTest.java 43 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/controller/TChargingOrderController.java
@@ -304,12 +304,12 @@
            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);
                    }
                }
            }
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/dto/FranchiseeNewUserStat.java
File was deleted
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/dto/FranchiseeUserFirstCharge.java
New file
@@ -0,0 +1,20 @@
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;
}
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/mapper/TChargingOrderMapper.java
@@ -477,10 +477,11 @@
                                               @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 已结束订单)。
ruoyi-service/ruoyi-order/src/main/java/com/ruoyi/order/support/FranchiseeUserStatistics.java
@@ -1,13 +1,10 @@
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;
@@ -22,37 +19,18 @@
    }
    /**
     * 旧入口:由订单列表推导"每日充电用户集合"后计算。
     * 新增用户为全平台口径(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);
@@ -89,12 +67,6 @@
        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) {
ruoyi-service/ruoyi-order/src/main/resources/mapper/order/TChargingOrderMapper.xml
@@ -3735,16 +3735,23 @@
        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 &gt;= #{start}
          AND create_time &lt; #{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 &gt;= #{start}
          AND end_time &lt; #{end}
        GROUP BY app_user_id
    </select>
    <!-- 流量来源统计(仅 status=5 已结束订单,空值归"明星充电"由服务端处理) -->
ruoyi-service/ruoyi-order/src/test/java/com/ruoyi/order/support/FranchiseeUserStatisticsTest.java
@@ -1,15 +1,16 @@
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;
@@ -19,28 +20,27 @@
    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);
@@ -54,8 +54,8 @@
    @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"));
@@ -67,12 +67,5 @@
    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;
    }
}